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 / Consent / ConsentGate.php

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

265 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The consent gate — may this submission become an opt-in?
4 *
5 * @package Forge12\DoubleOptIn\Consent
6 * @since 5.4.0
7 */
8
9 declare( strict_types=1 );
10
11 namespace Forge12\DoubleOptIn\Consent;
12
13 use Forge12\DoubleOptIn\Integration\SubmittedContent;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Decides whether a submission carries the acceptance the admin asked for.
21 *
22 * A form can name one of its fields as the acceptance field
23 * (`consent_field`). When it does, the visitor has to have confirmed it —
24 * otherwise the opt-in would ship a `consent_text` snapshot as proof of an
25 * agreement nobody ever gave. That is fabricated audit evidence, and GDPR
26 * Art. 7 is exactly about not producing it.
27 *
28 * ## Why this is its own class
29 *
30 * The check used to live inside `AbstractFormIntegration`, which meant it
31 * only ran for the integrations that extend it — CF7, WPForms, Gravity
32 * Forms, Avada. Elementor implements `FormIntegrationInterface` directly
33 * and submits through `OptInFrontend::maybeCreateOptIn()`, and so did the
34 * CF7/Avada legacy shims. For those the checkbox was recorded but never
35 * enforced. Both paths now call in here.
36 *
37 * ## Three states, not two
38 *
39 * The old check knew only "passed" and "rejected", and that conflated two
40 * very different situations:
41 *
42 * - the visitor did not tick the box → the visitor's doing, reject
43 * - the configured field is not on the form at all → the admin's doing
44 *
45 * Rejecting the second case takes a site's registrations offline for a
46 * mistake the visitor cannot see or fix. It has happened: before 5.3.2 the
47 * settings ran the field name through `sanitize_key()`, a form field named
48 * `Datenschutz` was stored as `datenschutz`, matched nothing at submit
49 * time, and every single submission was refused with "consent not given"
50 * until the customer reported the dead form (2026-08-27).
51 *
52 * So {@see self::FIELD_UNKNOWN} exists and never rejects. The opt-in is
53 * created, the mismatch is logged, and the admin hears about it through
54 * the Site Health check and the banner on the form's settings tab — where
55 * the person who can actually fix it will see it.
56 *
57 * ## What "known fields" buys us
58 *
59 * An unticked HTML checkbox is not submitted at all, so "absent from the
60 * payload" on its own cannot tell an unticked box from a field that no
61 * longer exists. The form's own field inventory
62 * ({@see \Forge12\DoubleOptIn\Integration\FormIntegrationInterface::getFormFields()})
63 * settles it: a field the form declares but the payload omits is an
64 * unticked box. Callers that cannot supply the inventory get the
65 * conservative reading — nothing is rejected unless the payload itself
66 * proves the field was there and empty.
67 */
68 final class ConsentGate {
69
70 /** Gate disabled, or the visitor confirmed. Create the opt-in. */
71 public const PASSED = 'passed';
72
73 /** The field belongs to this form and was not confirmed. Reject. */
74 public const NOT_GIVEN = 'not_given';
75
76 /** The configured field is not on this form. Do NOT reject; warn the admin. */
77 public const FIELD_UNKNOWN = 'field_unknown';
78
79 /**
80 * Evaluate the gate.
81 *
82 * @param string $consentField The configured acceptance field. Empty = gate off.
83 * @param mixed $content The submitted values, in any integration's shape.
84 * @param array<mixed,mixed> $knownFields The form's field inventory, `name => label`
85 * or a plain list of names. Empty when unknown.
86 *
87 * @return string One of the three class constants.
88 */
89 public static function evaluate( string $consentField, $content, array $knownFields = array() ): string {
90 $consentField = trim( $consentField );
91 if ( $consentField === '' ) {
92 return self::PASSED;
93 }
94
95 // Which key does the payload actually use? Settings written before
96 // 5.3.2 are lowercased, the payload is not.
97 $resolved = self::resolveInPayload( $content, $consentField );
98 $lookup = $resolved !== '' ? $resolved : $consentField;
99
100 if ( ! empty( SubmittedContent::findValue( $content, $lookup ) ) ) {
101 return self::PASSED;
102 }
103
104 if ( self::mirrorSaysTicked( $content, $lookup ) ) {
105 return self::PASSED;
106 }
107
108 // The payload carries the key but nothing in it — the field was on
109 // the form and the visitor left it alone. No inventory needed.
110 if ( $resolved !== '' ) {
111 return self::NOT_GIVEN;
112 }
113
114 // Not in the payload. Only the form's own definition can say
115 // whether that is an unticked checkbox or a stale setting.
116 if ( SubmittedContent::matchFieldName( $consentField, self::normalizeFieldNames( $knownFields ) ) !== '' ) {
117 return self::NOT_GIVEN;
118 }
119
120 return self::FIELD_UNKNOWN;
121 }
122
123 /**
124 * May a `NOT_GIVEN` verdict actually reject this submission?
125 *
126 * The one method here that touches WordPress. It exists as an escape
127 * hatch for the 5.4.0 rollout: enforcement is on by default, and a
128 * site that hits an edge case nobody anticipated can switch it off per
129 * form without downgrading or losing the rest of the release.
130 *
131 * Not a setting on purpose — a checkbox in the UI would invite people
132 * to turn the gate off to make an inconvenient rejection go away, and
133 * the rejection is the point.
134 *
135 * @param int $formId The form being submitted.
136 * @param string $integration The integration identifier.
137 */
138 public static function isEnforced( int $formId, string $integration = '' ): bool {
139 if ( ! function_exists( 'apply_filters' ) ) {
140 return true;
141 }
142
143 /**
144 * Filter whether the consent gate may reject a submission whose
145 * acceptance field was not confirmed.
146 *
147 * Returning false accepts the submission and logs a warning — the
148 * stored consent proof is then not backed by a confirmation.
149 *
150 * @since 5.4.0
151 *
152 * @param bool $enforce Whether to reject. Default true.
153 * @param int $formId The form being submitted.
154 * @param string $integration The integration identifier.
155 */
156 return (bool) apply_filters( 'f12_doi_enforce_consent_gate', true, $formId, $integration );
157 }
158
159 /**
160 * Reduce a field inventory to a plain list of field names.
161 *
162 * Every integration's `getFormFields()` returns `name => label`, but a
163 * caller that already has a list should not have to flip it.
164 *
165 * @param array<mixed,mixed> $fields
166 *
167 * @return array<int,string>
168 */
169 public static function normalizeFieldNames( array $fields ): array {
170 if ( $fields === array() ) {
171 return array();
172 }
173
174 $isList = array_keys( $fields ) === range( 0, count( $fields ) - 1 );
175 $names = $isList ? array_values( $fields ) : array_keys( $fields );
176
177 $out = array();
178 foreach ( $names as $name ) {
179 if ( is_scalar( $name ) && (string) $name !== '' ) {
180 $out[] = (string) $name;
181 }
182 }
183
184 return $out;
185 }
186
187 /**
188 * The canonical key the payload uses for this field, or '' when the
189 * payload does not carry it at all.
190 *
191 * @param mixed $content
192 * @param string $consentField
193 */
194 private static function resolveInPayload( $content, string $consentField ): string {
195 $names = array();
196 $levels = self::payloadLevels( $content );
197 foreach ( $levels as $level ) {
198 foreach ( array_keys( $level ) as $key ) {
199 $names[] = (string) $key;
200 }
201 }
202
203 return SubmittedContent::matchFieldName( $consentField, $names );
204 }
205
206 /**
207 * WPForms ships every checkbox twice: the bare id holds the joined
208 * display labels, and `field_{id}` holds the structured record. When
209 * the labels are empty the joined string is empty too, so a ticked box
210 * reads as untouched — the `value_raw` array in the mirror is the
211 * honest signal. Reported 2026-05-13.
212 *
213 * @param mixed $content
214 * @param string $consentField
215 */
216 private static function mirrorSaysTicked( $content, string $consentField ): bool {
217 $mirror = SubmittedContent::findValue( $content, 'field_' . $consentField );
218 if ( ! is_array( $mirror ) ) {
219 return false;
220 }
221
222 $candidates = array(
223 $mirror['value_raw'] ?? null,
224 $mirror['value'] ?? null,
225 );
226
227 foreach ( $candidates as $candidate ) {
228 if ( is_array( $candidate ) ) {
229 foreach ( $candidate as $entry ) {
230 if ( is_scalar( $entry ) && (string) $entry !== '' ) {
231 return true;
232 }
233 }
234 continue;
235 }
236 if ( is_scalar( $candidate ) && (string) $candidate !== '' ) {
237 return true;
238 }
239 }
240
241 return false;
242 }
243
244 /**
245 * Top level plus the unwrapped field map, in probe order.
246 *
247 * @param mixed $content
248 *
249 * @return array<int,array<mixed,mixed>>
250 */
251 private static function payloadLevels( $content ): array {
252 if ( ! is_array( $content ) ) {
253 return array();
254 }
255
256 $levels = array( $content );
257 $fields = SubmittedContent::unwrapFields( $content );
258 if ( $fields !== $content ) {
259 $levels[] = $fields;
260 }
261
262 return $levels;
263 }
264 }
265