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 / FormSettings / FormSettingsValidator.php

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

321 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Form Settings Validator
4 *
5 * @package Forge12\DoubleOptIn\FormSettings
6 * @since 4.1.0
7 */
8
9 namespace Forge12\DoubleOptIn\FormSettings;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Class FormSettingsValidator
17 *
18 * Validates form settings before saving.
19 */
20 class FormSettingsValidator {
21
22 /**
23 * Validate form settings.
24 *
25 * @param FormSettingsDTO $settings The settings to validate.
26 *
27 * @return array Array of validation errors. Empty if valid.
28 */
29 public function validate( FormSettingsDTO $settings ): array {
30 $errors = array();
31
32 // Only validate if enabled
33 if ( ! $settings->enabled ) {
34 return $errors;
35 }
36
37 // Hart-required fields — delegate to the DTO so save-time and
38 // completeness-gate share one source of truth (plan/doi-completeness-gate.md §2.1).
39 // The DTO returns stable string IDs; we map them to translatable messages here.
40 foreach ( $settings->getMissingRequiredFields() as $field ) {
41 $errors[ $field ] = $this->messageForMissingField( $field );
42 }
43
44 // Format-only checks live in the Validator (the DTO is shape-only,
45 // no I/O — page existence and category lookups need wpdb).
46
47 // Validate sender email format (soft-required: empty is OK,
48 // falls back to WP admin email at runtime — plan §5.3)
49 if ( ! empty( $settings->sender ) && ! $this->isValidEmailOrPlaceholder( $settings->sender ) ) {
50 $errors['sender'] = __( 'Invalid sender email format.', 'double-opt-in' );
51 }
52
53 // Validate confirmation page
54 if ( $settings->confirmationPage > 0 ) {
55 $page = get_post( $settings->confirmationPage );
56 if ( ! $page || $page->post_type !== 'page' ) {
57 $errors['confirmationPage'] = __( 'Invalid confirmation page selected.', 'double-opt-in' );
58 }
59 }
60
61 // Validate error redirect page
62 if ( $settings->errorRedirectPage > 0 ) {
63 $page = get_post( $settings->errorRedirectPage );
64 if ( ! $page || $page->post_type !== 'page' ) {
65 $errors['errorRedirectPage'] = __( 'Invalid error redirect page selected.', 'double-opt-in' );
66 }
67 }
68
69 // Validate category
70 if ( $settings->category > 0 ) {
71 $category = \forge12\contactform7\CF7DoubleOptIn\Category::get_by_id( $settings->category );
72 if ( ! $category ) {
73 $errors['category'] = __( 'Invalid category selected.', 'double-opt-in' );
74 }
75 }
76
77 return $errors;
78 }
79
80 /**
81 * Sanitize form settings.
82 *
83 * @param array $data Raw input data.
84 *
85 * @return FormSettingsDTO Sanitized settings DTO.
86 */
87 public function sanitize( array $data ): FormSettingsDTO {
88 $dto = new FormSettingsDTO();
89
90 $dto->enabled = ! empty( $data['enabled'] ) || ! empty( $data['enable'] );
91
92 $dto->sender = isset( $data['sender'] )
93 ? $this->sanitizeEmailOrPlaceholder( $data['sender'] )
94 : '';
95
96 $dto->senderName = isset( $data['senderName'] ) || isset( $data['sender_name'] )
97 ? sanitize_text_field( $data['senderName'] ?? $data['sender_name'] )
98 : '';
99
100 $dto->subject = isset( $data['subject'] )
101 ? sanitize_text_field( $data['subject'] )
102 : '';
103
104 $dto->body = isset( $data['body'] )
105 ? $this->sanitizeBody( $data['body'] )
106 : '';
107
108 $dto->recipient = isset( $data['recipient'] )
109 ? sanitize_text_field( $data['recipient'] )
110 : '';
111
112 $dto->confirmationPage = isset( $data['confirmationPage'] ) || isset( $data['page'] )
113 ? (int) ( $data['confirmationPage'] ?? $data['page'] )
114 : -1;
115
116 $dto->errorRedirectPage = isset( $data['errorRedirectPage'] ) || isset( $data['error_page'] )
117 ? (int) ( $data['errorRedirectPage'] ?? $data['error_page'] )
118 : -1;
119
120 $dto->conditions = isset( $data['conditions'] )
121 ? sanitize_text_field( $data['conditions'] )
122 : 'disabled';
123
124 $dto->template = isset( $data['template'] )
125 ? sanitize_text_field( $data['template'] )
126 : '';
127
128 $dto->category = isset( $data['category'] )
129 ? absint( $data['category'] )
130 : 0;
131
132 $dto->consentText = isset( $data['consentText'] ) || isset( $data['consent_text'] )
133 ? sanitize_textarea_field( $data['consentText'] ?? $data['consent_text'] )
134 : '';
135
136 // Consent acknowledgment field — name of the form field that
137 // captures the user's explicit consent (e.g. CF7 [acceptance]).
138 // It has to match a real form-field name at submit time, so it
139 // is normalised to what a field name can legally contain — but
140 // NOT lowercased. See sanitizeFieldName().
141 $dto->consentField = isset( $data['consentField'] ) || isset( $data['consent_field'] )
142 ? $this->sanitizeFieldName( (string) ( $data['consentField'] ?? $data['consent_field'] ) )
143 : '';
144
145 // Field-mapping (placeholder-tag → form-field-name) for the
146 // Mapping tab. Sanitize each key + value to text-safe strings
147 // since both end up in the email body / database meta.
148 $rawMapping = $data['fieldMapping'] ?? $data['field_mapping'] ?? array();
149 if ( is_array( $rawMapping ) ) {
150 $mapping = array();
151 foreach ( $rawMapping as $key => $value ) {
152 $mapping[ sanitize_key( (string) $key ) ] = sanitize_text_field( (string) $value );
153 }
154 $dto->fieldMapping = $mapping;
155 }
156
157 /**
158 * Filter to allow addons to sanitize and contribute their own
159 * extensionData fields. Mirrors `f12_doi_settings_dto_from_array`
160 * (load-side) so save and load are symmetric — both go through
161 * the same addon-side filter chain. An addon that registers one
162 * filter without the other silently loses data on roundtrip.
163 *
164 * @since 4.4.0
165 *
166 * @param FormSettingsDTO $dto The DTO populated with Core fields.
167 * @param array $data The raw input array.
168 */
169 $dto = apply_filters( 'f12_doi_settings_dto_sanitize', $dto, $data );
170
171 return $dto;
172 }
173
174 /**
175 * Map a missing-required-field ID (as returned by
176 * {@see FormSettingsDTO::getMissingRequiredFields()}) to a translatable
177 * user-facing error message.
178 *
179 * Addon-contributed IDs fall through to a generic message; addons
180 * that need bespoke wording should filter the messages array via
181 * `f12_doi_form_missing_field_messages` (registered at apply_filters
182 * time below).
183 */
184 private function messageForMissingField( string $field ): string {
185 $messages = apply_filters(
186 'f12_doi_form_missing_field_messages',
187 array(
188 'recipient' => __( 'Recipient field is required.', 'double-opt-in' ),
189 'subject' => __( 'Email subject is required.', 'double-opt-in' ),
190 'body_or_template' => __( 'Email body must contain the [doubleoptinlink] placeholder or a template must be selected.', 'double-opt-in' ),
191 )
192 );
193
194 return $messages[ $field ] ?? __( 'Required field is missing.', 'double-opt-in' );
195 }
196
197 /**
198 * Normalise a form-field name the admin picked from a dropdown.
199 *
200 * `sanitize_key()` used to do this job and was the wrong tool: it
201 * lowercases. Form systems do not.
202 *
203 * Elementor's field-id control states its own rule as "This field
204 * allows A-z 0-9 & underscore chars without spaces" — capitals
205 * included — and a German site names its consent checkbox
206 * `Datenschutz`. That was stored as `datenschutz`, never matched the
207 * form's field list again, and the settings page showed "the
208 * selected acceptance field does not exist on this form" for good,
209 * because re-picking it from the dropdown lowercased it again
210 * (customer report 2026-08-27). On integrations that enforce the
211 * consent gate the same mangling rejected every submission outright.
212 *
213 * Same character allow-list as before, plus the column width
214 * (`consent_field` is `varchar(64)`; a longer value would be
215 * truncated by MySQL and then never match either). Only the
216 * lowercasing is gone.
217 *
218 * @param string $value Raw field name from the request.
219 *
220 * @return string Sanitized field name.
221 */
222 private function sanitizeFieldName( string $value ): string {
223 $value = preg_replace( '/[^A-Za-z0-9_\-]/', '', trim( $value ) );
224
225 if ( ! is_string( $value ) ) {
226 return '';
227 }
228
229 return substr( $value, 0, 64 );
230 }
231
232 /**
233 * Check if a value is a valid email or placeholder.
234 *
235 * @param string $value The value to check.
236 *
237 * @return bool True if valid.
238 */
239 private function isValidEmailOrPlaceholder( string $value ): bool {
240 // Check for placeholder format [field_name]
241 if ( preg_match( '/^\[.+\]$/', $value ) ) {
242 return true;
243 }
244
245 return is_email( $value ) !== false;
246 }
247
248 /**
249 * Sanitize an email or placeholder value.
250 *
251 * @param string $value The value to sanitize.
252 *
253 * @return string Sanitized value.
254 */
255 private function sanitizeEmailOrPlaceholder( string $value ): string {
256 $value = trim( $value );
257
258 // If it's a placeholder, sanitize as text
259 if ( preg_match( '/^\[.+\]$/', $value ) ) {
260 return sanitize_text_field( $value );
261 }
262
263 // Otherwise sanitize as email
264 $email = sanitize_email( $value );
265 return $email ?: $value;
266 }
267
268 /**
269 * Sanitize email body content.
270 *
271 * Allows HTML but removes dangerous content.
272 *
273 * @param string $body The body content.
274 *
275 * @return string Sanitized body.
276 */
277 private function sanitizeBody( string $body ): string {
278 // Allow HTML tags used in email templates
279 $allowedHtml = wp_kses_allowed_html( 'post' );
280
281 // Add additional tags commonly used in emails
282 $allowedHtml['style'] = array();
283 $allowedHtml['center'] = array();
284 $allowedHtml['table'] = array(
285 'class' => true,
286 'id' => true,
287 'style' => true,
288 'width' => true,
289 'height' => true,
290 'cellpadding' => true,
291 'cellspacing' => true,
292 'border' => true,
293 'align' => true,
294 'bgcolor' => true,
295 );
296 $allowedHtml['tr'] = array(
297 'class' => true,
298 'style' => true,
299 'align' => true,
300 'valign' => true,
301 );
302 $allowedHtml['td'] = array(
303 'class' => true,
304 'style' => true,
305 'width' => true,
306 'height' => true,
307 'align' => true,
308 'valign' => true,
309 'bgcolor' => true,
310 'colspan' => true,
311 'rowspan' => true,
312 );
313 $allowedHtml['th'] = $allowedHtml['td'];
314 $allowedHtml['thead'] = array();
315 $allowedHtml['tbody'] = array();
316 $allowedHtml['tfoot'] = array();
317
318 return wp_kses( $body, $allowedHtml );
319 }
320 }
321