PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.3.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.3.1
5.6.2 5.6.3 5.6.1 5.6.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 All 38 releases
double-opt-in / src / Integration / AbstractFormIntegration.php

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

1,385 lines 39.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abstract Form Integration
4 *
5 * @package Forge12\DoubleOptIn\Integration
6 * @since 4.0.0
7 */
8
9 namespace Forge12\DoubleOptIn\Integration;
10
11 use Forge12\DoubleOptIn\Container\Container;
12 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
13 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
14 use Forge12\DoubleOptIn\Events\Integration\FormSubmissionEvent;
15 use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent;
16 use Forge12\DoubleOptIn\Events\Lifecycle\OptInCreatedEvent;
17 use Forge12\DoubleOptIn\Files\FileStorage;
18 use Forge12\DoubleOptIn\Frontend\ErrorNotification;
19 use Forge12\DoubleOptIn\Service\RateLimiter;
20 use forge12\contactform7\CF7DoubleOptIn\CF7DoubleOptIn;
21 use forge12\contactform7\CF7DoubleOptIn\IPHelper;
22 use forge12\contactform7\CF7DoubleOptIn\OptIn;
23 use forge12\contactform7\CF7DoubleOptIn\OptInFrontend;
24 use forge12\contactform7\CF7DoubleOptIn\Telemetry;
25 use Forge12\Shared\LoggerInterface;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 /**
32 * Class AbstractFormIntegration
33 *
34 * @api
35 *
36 * Base class providing common functionality for all form integrations.
37 * Extracted from the legacy OptInFrontend class to provide reusable logic.
38 * Addons that integrate a form system extend this class to reduce
39 * boilerplate — see docs/addon-api.md §4.3. Covered by the Addon API
40 * semver policy as of Core API 4.3.0.
41 */
42 abstract class AbstractFormIntegration implements FormIntegrationInterface {
43
44 /**
45 * Logger instance.
46 *
47 * @var LoggerInterface
48 */
49 protected LoggerInterface $logger;
50
51 /**
52 * Validation status from the last validateOptIn() call.
53 *
54 * @var string
55 */
56 private static string $validationStatus = '';
57
58 /**
59 * Stored hCaptcha CF7 instance for restore after mail send.
60 *
61 * @var object|null
62 */
63 private $hcaptchaCf7Instance = null;
64
65 /**
66 * Stored hCaptcha CF7 filter priority for restore after mail send.
67 *
68 * @var int
69 */
70 private int $hcaptchaCf7Priority = 20;
71
72 /**
73 * Last error from the most recent createOptIn() call.
74 *
75 * @var OptInError|null
76 */
77 private static ?OptInError $lastError = null;
78
79 /**
80 * Get the validation status from the last validateOptIn() call.
81 *
82 * @return string One of: '', 'confirmed', 'already_confirmed', 'expired', 'not_found'.
83 */
84 public static function getValidationStatus(): string {
85 return self::$validationStatus;
86 }
87
88 /**
89 * Set the validation status.
90 *
91 * @param string $status The validation status.
92 */
93 private static function setValidationStatus( string $status ): void {
94 self::$validationStatus = $status;
95 }
96
97 /**
98 * Get the last error from the most recent createOptIn() call.
99 *
100 * @return OptInError|null The error, or null if no error occurred.
101 */
102 public static function getLastError(): ?OptInError {
103 return self::$lastError;
104 }
105
106 /**
107 * Clear the last error.
108 */
109 private static function clearLastError(): void {
110 self::$lastError = null;
111 }
112
113 /**
114 * Set the last error and store it for the frontend notification system.
115 *
116 * @param OptInError $error The error that occurred.
117 * @param int $formId The form ID.
118 */
119 private static function setLastError( OptInError $error, int $formId ): void {
120 self::$lastError = $error;
121 ErrorNotification::store( $error, $formId );
122 }
123
124 /**
125 * Get the recipient validation error from the last createOptIn() call.
126 *
127 * @deprecated Use getLastError() instead.
128 *
129 * @return string The error message, or empty string if no error.
130 */
131 public static function getLastRecipientValidationError(): string {
132 if ( self::$lastError && self::$lastError->getCode() === OptInError::RECIPIENT_INVALID ) {
133 return self::$lastError->getMessage();
134 }
135 return '';
136 }
137
138 /**
139 * Constructor.
140 *
141 * @param LoggerInterface $logger The logger instance.
142 */
143 public function __construct( LoggerInterface $logger ) {
144 $this->logger = $logger;
145 }
146
147 /**
148 * Get the logger instance.
149 *
150 * @return LoggerInterface
151 */
152 protected function getLogger(): LoggerInterface {
153 return $this->logger;
154 }
155
156 /**
157 * {@inheritdoc}
158 */
159 public function getHookPriority(): int {
160 return 10;
161 }
162
163 /**
164 * {@inheritdoc}
165 */
166 public function getFormParameter( int $formId ): array {
167 return CF7DoubleOptIn::getInstance()->getParameter( $formId );
168 }
169
170 /**
171 * {@inheritdoc}
172 */
173 public function isOptInEnabled( int $formId ): bool {
174 // Disable if opt-in confirmation is in progress
175 if ( isset( $_GET['optin'] ) ) {
176 $this->getLogger()->debug(
177 'Opt-in disabled due to optin flag in GET request',
178 array(
179 'plugin' => 'double-opt-in',
180 'class' => static::class,
181 )
182 );
183 return false;
184 }
185
186 $parameter = $this->getFormParameter( $formId );
187
188 if ( (int) ( $parameter['enable'] ?? 0 ) !== 1 ) {
189 $this->getLogger()->debug(
190 'Opt-in not enabled in form parameter',
191 array(
192 'plugin' => 'double-opt-in',
193 'form_id' => $formId,
194 )
195 );
196 return false;
197 }
198
199 // Check the custom condition
200 if ( isset( $parameter['conditions'] ) ) {
201 $condition = sanitize_text_field( $parameter['conditions'] );
202
203 if ( ( $condition !== 'disable' && $condition !== 'disabled' )
204 && ( ! isset( $_POST[ $condition ] ) || empty( $_POST[ $condition ] ) ) ) {
205 $this->getLogger()->debug(
206 'Opt-in disabled due to unmet custom condition',
207 array(
208 'plugin' => 'double-opt-in',
209 'condition' => $condition,
210 )
211 );
212 return false;
213 }
214 }
215
216 return true;
217 }
218
219 /**
220 * Create an OptIn record from form data.
221 *
222 * @param FormDataInterface $formData The normalized form data.
223 * @param array $formParameter The form configuration.
224 *
225 * @return OptIn|null The created OptIn or null on failure.
226 */
227 protected function createOptIn( FormDataInterface $formData, array $formParameter ): ?OptIn {
228 $this->getLogger()->debug(
229 'Creating OptIn record',
230 array(
231 'plugin' => 'double-opt-in',
232 'class' => static::class,
233 'form_id' => $formData->getFormId(),
234 'form_type' => $formData->getFormType(),
235 )
236 );
237
238 // Clear previous error
239 self::clearLastError();
240
241 // Dispatch FormSubmissionEvent to allow modifications/cancellation
242
243 $event = $this->dispatchFormSubmissionEvent( $formData );
244 if ( $event && $event->shouldSkipOptIn() ) {
245 $this->getLogger()->info(
246 'OptIn skipped by FormSubmissionEvent',
247 array(
248 'plugin' => 'double-opt-in',
249 'form_id' => $formData->getFormId(),
250 )
251 );
252 self::setLastError(
253 OptInError::fromCode( OptInError::SUBMISSION_CANCELLED, array( 'form_id' => $formData->getFormId() ) ),
254 $formData->getFormId()
255 );
256 return null;
257 }
258
259 // Store uploaded files
260 $files = $this->storeFiles( $formData->getFiles() );
261
262 // Filter parameters before saving
263 $fields = apply_filters( 'f12_cf7_doubleoptin_add_request_parameter', $formData->getFields() );
264
265 // Resolve recipient email
266 $recipient = $this->resolveRecipient( $formData, $formParameter );
267
268 if ( empty( $recipient ) ) {
269 $this->getLogger()->warning(
270 'No recipient found, skipping OptIn creation',
271 array(
272 'plugin' => 'double-opt-in',
273 'form_id' => $formData->getFormId(),
274 )
275 );
276 self::setLastError(
277 OptInError::fromCode( OptInError::NO_RECIPIENT, array( 'form_id' => $formData->getFormId() ) ),
278 $formData->getFormId()
279 );
280 return null;
281 }
282
283 $consentError = $this->validateConsentAcceptance( $formData, $formParameter );
284 if ( $consentError !== null ) {
285 $this->getLogger()->info(
286 'Consent acceptance not given, rejecting OptIn',
287 array(
288 'plugin' => 'double-opt-in',
289 'form_id' => $formData->getFormId(),
290 'consent_field' => $consentError->getContext()['consent_field'] ?? '',
291 )
292 );
293 do_action(
294 'f12_cf7_doubleoptin_consent_not_given',
295 $formData->getFormId(),
296 $consentError->getContext()['consent_field'] ?? ''
297 );
298 self::setLastError( $consentError, $formData->getFormId() );
299 return null;
300 }
301
302 // Rate-Limiting: Check IP and email limits before creating OptIn
303 $rateLimiter = new RateLimiter();
304 $settings = CF7DoubleOptIn::getInstance()->getSettings();
305 $rateLimitIp = (int) ( $settings['rate_limit_ip'] ?? 5 );
306 $rateLimitEmail = (int) ( $settings['rate_limit_email'] ?? 3 );
307 $rateLimitWindow = (int) ( $settings['rate_limit_window'] ?? 60 );
308
309 $ip = IPHelper::getIPAdress();
310 if ( ! $rateLimiter->isAllowed( 'ip', $ip, $rateLimitIp, $rateLimitWindow ) ) {
311 $this->getLogger()->warning(
312 'Rate limit exceeded for IP',
313 array(
314 'plugin' => 'double-opt-in',
315 'ip' => $ip,
316 'form_id' => $formData->getFormId(),
317 )
318 );
319 do_action( 'f12_cf7_doubleoptin_rate_limited', 'ip', $ip, $formData->getFormId() );
320 self::setLastError(
321 OptInError::fromCode(
322 OptInError::RATE_LIMIT_IP,
323 array(
324 'ip' => $ip,
325 'form_id' => $formData->getFormId(),
326 )
327 ),
328 $formData->getFormId()
329 );
330 return null;
331 }
332
333 if ( ! $rateLimiter->isAllowed( 'email', $recipient, $rateLimitEmail, $rateLimitWindow ) ) {
334 $this->getLogger()->warning(
335 'Rate limit exceeded for email',
336 array(
337 'plugin' => 'double-opt-in',
338 'email' => $recipient,
339 'form_id' => $formData->getFormId(),
340 )
341 );
342 do_action( 'f12_cf7_doubleoptin_rate_limited', 'email', $recipient, $formData->getFormId() );
343 self::setLastError(
344 OptInError::fromCode(
345 OptInError::RATE_LIMIT_EMAIL,
346 array(
347 'email' => $recipient,
348 'form_id' => $formData->getFormId(),
349 )
350 ),
351 $formData->getFormId()
352 );
353 return null;
354 }
355
356 // Validate recipient (extensible via Pro MX check)
357 $recipientValid = apply_filters(
358 'f12_cf7_doubleoptin_validate_recipient',
359 true,
360 $recipient,
361 $formData
362 );
363
364 if ( $recipientValid !== true ) {
365 $errorMsg = is_string( $recipientValid ) ? $recipientValid : '';
366 $errorCode = OptInError::RECIPIENT_INVALID;
367
368 // Unique Email rejection gets its own error code
369 if ( $errorMsg === 'unique_email_rejected' ) {
370 $errorCode = OptInError::UNIQUE_EMAIL_DUPLICATE;
371 $errorMsg = OptInError::fromCode( OptInError::UNIQUE_EMAIL_DUPLICATE )->getMessage();
372 }
373
374 $this->getLogger()->warning(
375 'Recipient validation failed',
376 array(
377 'plugin' => 'double-opt-in',
378 'email' => $recipient,
379 'form_id' => $formData->getFormId(),
380 'reason' => $errorMsg,
381 )
382 );
383 do_action( 'f12_cf7_doubleoptin_recipient_invalid', $recipient, $formData->getFormId(), $errorMsg );
384 self::setLastError(
385 new OptInError(
386 $errorCode,
387 ! empty( $errorMsg ) ? $errorMsg : OptInError::fromCode( OptInError::RECIPIENT_INVALID )->getMessage(),
388 array(
389 'email' => $recipient,
390 'form_id' => $formData->getFormId(),
391 )
392 ),
393 $formData->getFormId()
394 );
395 return null;
396 }
397
398 $properties = $this->buildOptInProperties( $formData, $formParameter, $recipient, $fields, $files );
399
400 $optIn = new OptIn( $this->getLogger(), $properties );
401
402 if ( $optIn->save() ) {
403 $this->getLogger()->info(
404 'OptIn record created successfully',
405 array(
406 'plugin' => 'double-opt-in',
407 'optin_id' => $optIn->get_id(),
408 'form_id' => $formData->getFormId(),
409 )
410 );
411
412 // Dispatch typed event
413 $this->dispatchOptInCreatedEvent( $optIn, $formData );
414
415 // Track telemetry
416 $telemetry = new Telemetry( $this->getLogger() );
417 $telemetry->increment( 'total_optins' );
418 $telemetry->increment( $this->getIdentifier() . '_optins' );
419
420 return $optIn;
421 }
422
423 $this->getLogger()->error(
424 'Failed to save OptIn record',
425 array(
426 'plugin' => 'double-opt-in',
427 'form_id' => $formData->getFormId(),
428 )
429 );
430
431 do_action( 'f12_cf7_doubleoptin_creation_failed', $formData->getFormId(), $recipient );
432
433 self::setLastError(
434 OptInError::fromCode( OptInError::SAVE_FAILED, array( 'form_id' => $formData->getFormId() ) ),
435 $formData->getFormId()
436 );
437
438 return null;
439 }
440
441 /**
442 * Validate the consent-acceptance gate (GDPR Art. 7).
443 *
444 * When the form has a configured `consent_field`, the user must
445 * have actively confirmed it. Otherwise we'd be storing a
446 * `consent_text` snapshot the user never saw — fabricated audit
447 * evidence. Empty `consent_field` means "no gate"
448 * (backward-compat; admins who haven't migrated yet keep working).
449 *
450 * Extracted into its own method so the gate behavior is testable
451 * in isolation — the full createOptIn() flow has too many
452 * side-effects (rate-limiting, file storage, container access)
453 * for clean unit testing of just this branch.
454 *
455 * @param FormDataInterface $formData The submitted form data.
456 * @param array<string, mixed> $formParameter The form-settings snapshot.
457 *
458 * @return OptInError|null Error when the gate fails; null when it
459 * passes (gate disabled or value truthy).
460 */
461 protected function validateConsentAcceptance( FormDataInterface $formData, array $formParameter ): ?OptInError {
462 $consentField = (string) ( $formParameter['consent_field'] ?? '' );
463 if ( $consentField === '' ) {
464 return null;
465 }
466
467 $consentValue = $formData->getField( $consentField );
468
469 // Diagnostic — 2026-05-13 user report: WPForms Checkbox field
470 // ticked, gate still rejects. Need to see the actual shape of
471 // `$consentValue` to know whether `! empty()` is the wrong
472 // predicate for WPForms checkbox payloads (e.g. array with
473 // empty string, scalar 0, etc.).
474 $this->getLogger()->info(
475 'Consent gate evaluation',
476 array(
477 'plugin' => 'double-opt-in',
478 'form_id' => $formData->getFormId(),
479 'consent_field' => $consentField,
480 'value_type' => gettype( $consentValue ),
481 'value_preview' => is_scalar( $consentValue )
482 ? (string) $consentValue
483 : wp_json_encode( $consentValue ),
484 'is_empty' => empty( $consentValue ),
485 )
486 );
487
488 if ( ! empty( $consentValue ) ) {
489 return null;
490 }
491
492 // Fallback for WPForms checkbox shape: when the user ticked
493 // the box, the bare-id key may carry the joined-string `value`
494 // while the truthful "did the user actually tick anything"
495 // signal lives in the `field_{id}` mirror's `value_raw`
496 // (array of internal slugs). If `value_raw` is a non-empty
497 // array with at least one non-empty entry, treat as consent
498 // given — `empty()` over the joined string is a false
499 // negative when the checkbox's display labels are empty.
500 $mirror = $formData->getField( 'field_' . $consentField );
501 if ( is_array( $mirror ) ) {
502 $valueRaw = $mirror['value_raw'] ?? null;
503 $value = $mirror['value'] ?? null;
504 $hasTicked = false;
505 foreach ( array( $valueRaw, $value ) as $candidate ) {
506 if ( is_array( $candidate ) ) {
507 foreach ( $candidate as $entry ) {
508 if ( is_scalar( $entry ) && (string) $entry !== '' ) {
509 $hasTicked = true;
510 break 2;
511 }
512 }
513 } elseif ( is_scalar( $candidate ) && (string) $candidate !== '' ) {
514 $hasTicked = true;
515 break;
516 }
517 }
518 if ( $hasTicked ) {
519 $this->getLogger()->info(
520 'Consent gate passed via field_{id} mirror fallback',
521 array(
522 'plugin' => 'double-opt-in',
523 'form_id' => $formData->getFormId(),
524 'consent_field' => $consentField,
525 )
526 );
527 return null;
528 }
529 }
530
531 return OptInError::fromCode(
532 OptInError::CONSENT_NOT_GIVEN,
533 array(
534 'form_id' => $formData->getFormId(),
535 'consent_field' => $consentField,
536 )
537 );
538 }
539
540 /**
541 * Build the OptIn properties array that will be persisted on
542 * record creation. Extracted from {@see createOptIn()} so the
543 * field-coverage contract is testable in isolation — the full
544 * createOptIn() flow has too many side-effects (rate-limiting,
545 * file storage, container access) for clean unit testing.
546 *
547 * Snapshot semantics:
548 * - `consent_text` is captured per GDPR Art. 7 — the consent
549 * record reflects the wording the user actually agreed to,
550 * even if the form's settings change later.
551 * - Custom addon fields land via the `f12_doi_optin_properties`
552 * filter; addons that contribute a per-form setting hook here
553 * to persist a snapshot with each opt-in.
554 *
555 * @param FormDataInterface $formData The submitted form data.
556 * @param array<string, mixed> $formParameter The form-settings snapshot.
557 * @param string $recipient The resolved recipient email.
558 * @param array<string, mixed> $fields Filtered form fields.
559 * @param array<string, mixed> $files Stored file references.
560 *
561 * @return array<string, mixed>
562 */
563 protected function buildOptInProperties(
564 FormDataInterface $formData,
565 array $formParameter,
566 string $recipient,
567 array $fields,
568 array $files
569 ): array {
570 $properties = array(
571 'cf_form_id' => $formData->getFormId(),
572 'doubleoptin' => 0,
573 'createtime' => time(),
574 'content' => maybe_serialize( $fields ),
575 'files' => maybe_serialize( $files ),
576 'ipaddr_register' => IPHelper::getIPAdress(),
577 'category' => (int) ( $formParameter['category'] ?? 0 ),
578 'form' => $formData->getFormHtml(),
579 'email' => $recipient,
580 'consent_text' => (string) ( $formParameter['consent_text'] ?? '' ),
581 'consent_field' => (string) ( $formParameter['consent_field'] ?? '' ),
582 );
583
584 /**
585 * Filter the OptIn properties array before the record is
586 * created. Addons hook this to snapshot their own per-form
587 * settings into the opt-in record at submit time. Mirrors the
588 * symmetric DTO filter pattern (`f12_doi_settings_dto_from_array`
589 * / `f12_doi_settings_dto_sanitize`) — an addon that contributes
590 * a per-form setting AND wants it persisted with each opt-in
591 * snapshots it through this filter.
592 *
593 * @since 4.4.0
594 *
595 * @param array<string, mixed> $properties The properties array
596 * for the new OptIn.
597 * @param FormDataInterface $formData The submitted form data.
598 * @param array<string, mixed> $formParameter The form-settings snapshot.
599 */
600 return apply_filters( 'f12_doi_optin_properties', $properties, $formData, $formParameter );
601 }
602
603 /**
604 * Prepare the opt-in mail body with placeholders replaced.
605 *
606 * @param string $body The mail body template.
607 * @param OptIn $optIn The OptIn record.
608 * @param array $formParameter The form configuration.
609 *
610 * @return string The processed mail body.
611 */
612 protected function prepareMailBody( string $body, OptIn $optIn, array $formParameter ): string {
613 // Replace system placeholders
614 $body = $this->addSystemPlaceholders( $body, $optIn, $formParameter );
615
616 // Replace form field placeholders
617 $formData = maybe_unserialize( $optIn->get_content() );
618 if ( is_array( $formData ) ) {
619 // Handle nested content structure (e.g., Avada stores {data: {...}, field_labels: {...}, ...})
620 // Extract the flat field data for placeholder replacement
621 $fieldData = isset( $formData['data'] ) && is_array( $formData['data'] ) ? $formData['data'] : $formData;
622
623 $body = PlaceholderMapper::replacePlaceholders(
624 $body,
625 $fieldData,
626 $optIn->get_cf_form_id(),
627 array(),
628 $this->getIdentifier()
629 );
630 }
631
632 return $body;
633 }
634
635 /**
636 * Add system placeholders to the mail body.
637 *
638 * @param string $body The mail body.
639 * @param OptIn $optIn The OptIn record.
640 * @param array $formParameter The form configuration.
641 *
642 * @return string The body with placeholders replaced.
643 */
644 protected function addSystemPlaceholders( string $body, OptIn $optIn, array $formParameter ): string {
645 $placeholders = array(
646 // User-influenced (the submit page URL incl. query string) — sanitise
647 // as a URL so a crafted `?x="><script>` can't reflect into the mail
648 // HTML. esc_url_raw (not esc_url) keeps ampersands un-entity-encoded
649 // so the plain-text mail variant stays intact too.
650 'doubleoptin_form_url' => esc_url_raw( (string) ( $formParameter['formUrl'] ?? '' ) ),
651 'doubleoptin_form_subject' => $formParameter['subject'] ?? '',
652 // wp_date() formats in the site's timezone without mutating PHP's
653 // global timezone (the old date() + date_default_timezone_set() did).
654 'doubleoptin_form_date' => wp_date( get_option( 'date_format' ) ),
655 'doubleoptin_form_time' => wp_date( get_option( 'time_format' ) ),
656 'doubleoptin_form_email' => get_option( 'admin_email' ),
657 'doubleoptinlink' => $optIn->get_link_optin( $formParameter ),
658 'doubleoptoutlink' => $optIn->get_link_optout(),
659 'doubleoptin_privacy_url' => $this->getPrivacyPolicyUrl(),
660 );
661
662 foreach ( $placeholders as $key => $value ) {
663 if ( is_array( $value ) || is_object( $value ) ) {
664 $value = wp_json_encode( $value );
665 }
666 $body = str_replace( '[' . $key . ']', (string) $value, $body );
667 }
668
669 return $body;
670 }
671
672 /**
673 * Get the privacy policy URL.
674 *
675 * @return string The privacy policy URL or empty string.
676 */
677 protected function getPrivacyPolicyUrl(): string {
678 $settings = CF7DoubleOptIn::getInstance()->getSettings();
679 $pageId = (int) ( $settings['privacy_policy_page'] ?? 0 );
680
681 if ( $pageId > 0 ) {
682 $url = get_permalink( $pageId );
683 if ( $url ) {
684 return $url;
685 }
686 }
687
688 // Fallback to WordPress privacy policy page
689 $wpPrivacyPageId = (int) get_option( 'wp_page_for_privacy_policy', 0 );
690 if ( $wpPrivacyPageId > 0 ) {
691 $url = get_permalink( $wpPrivacyPageId );
692 if ( $url ) {
693 return $url;
694 }
695 }
696
697 return '';
698 }
699
700 /**
701 * Store uploaded files for later use after opt-in confirmation.
702 *
703 * @param array $files The uploaded files.
704 *
705 * @return array The stored file paths.
706 */
707 protected function storeFiles( array $files ): array {
708 // File-lifecycle plan, Step 1 (2026-05-07): delegate to the
709 // centralised FileStorage service. This moves files into
710 // `wp-content/uploads/f12-doi/pending/` (deny-from-all) with
711 // random hex names, instead of the pre-4.3 in-tmp-dir copy.
712 // The legacy copyAndRenameFile path below stays for now as a
713 // fallback if FileStorage construction fails — removed in
714 // Schritt 2 once each integration's hand-off is wired up.
715 try {
716 $storage = new FileStorage( $this->logger );
717 return $storage->store( $files );
718 } catch ( \Throwable $e ) {
719 $this->logger->error(
720 'FileStorage unavailable, falling back to legacy in-tmp store',
721 array(
722 'plugin' => 'double-opt-in',
723 'error' => $e->getMessage(),
724 )
725 );
726 }
727
728 // ── Legacy fallback (pre-4.3) ─────────────────────────────────
729 $storedFiles = array();
730
731 if ( empty( $files ) ) {
732 return $storedFiles;
733 }
734
735 foreach ( $files as $key => $fileList ) {
736 if ( ! is_array( $fileList ) ) {
737 $fileList = array( $fileList );
738 }
739
740 foreach ( $fileList as $file ) {
741 if ( empty( $file ) || ! is_file( $file ) ) {
742 continue;
743 }
744
745 $newFile = $this->copyAndRenameFile( $file );
746 if ( $newFile ) {
747 $storedFiles[] = $newFile;
748 }
749 }
750 }
751
752 return $storedFiles;
753 }
754
755 /**
756 * Hand off the opt-in's stored files to the integration's own
757 * form-system entry (Avada form_entries / GF entry / WPForms
758 * entry / CF7 mail attachment). Per-integration override.
759 *
760 * Default behaviour: return false (no hand-off). The files stay
761 * in `pending/` until the OptIn is deleted (cron / manual /
762 * REST), at which point cascade-delete removes them.
763 *
764 * Contract (file-lifecycle plan, 2026-05-07):
765 *
766 * - true: hand-off succeeded. Caller (template-method below)
767 * will delete the pending/ copies — single source of
768 * truth = the integration's own DB. GDPR-compliant by
769 * construction (consent-bound retention).
770 *
771 * - false: hand-off failed or not implemented. Files stay in
772 * pending/ and are removed eventually via OptIn-deletion
773 * cascade (cron expiry or manual delete). No silent
774 * data loss.
775 *
776 * @param OptIn $optIn The just-confirmed opt-in record.
777 *
778 * @return bool true on successful hand-off, false otherwise.
779 *
780 * @since 4.3.0
781 */
782 public function handOffFilesToFormSystem( OptIn $optIn ): bool {
783 // Default: not implemented for this integration. Per-
784 // integration overrides in addon-avada / addon-cf7 / etc.
785 return false;
786 }
787
788 /**
789 * Template-method: process file hand-off when an opt-in confirms.
790 *
791 * Hooked on `f12_cf7_doubleoptin_after_confirm` at priority 5
792 * (BEFORE addon-specific listeners that fire at default 10), so
793 * file hand-off completes before any side-effects that might
794 * rely on the integration's entry being fully populated.
795 *
796 * Each subclass registers this in its own `registerHooks()` —
797 * see Schritt 2 per-integration commits.
798 *
799 * @param string $hash The confirmation hash (action arg).
800 * @param OptIn $optIn The confirmed opt-in (action arg).
801 *
802 * @since 4.3.0
803 */
804 final public function processFilesOnConfirm( string $hash, OptIn $optIn ): void {
805 // Bail for opt-ins that aren't this integration's responsibility.
806 // The after_confirm action fires for ALL types — every integration
807 // hooks it but only acts on its own.
808 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
809 return;
810 }
811
812 $rawFiles = (string) $optIn->get_files();
813 $decoded = $rawFiles === '' ? array() : maybe_unserialize( $rawFiles );
814 $decoded = is_array( $decoded ) ? $decoded : array();
815 $files = array_values(
816 array_filter(
817 $decoded,
818 static function ( $p ) { return is_string( $p ) && $p !== ''; }
819 )
820 );
821
822 if ( empty( $files ) ) {
823 return;
824 }
825
826 $handedOff = $this->handOffFilesToFormSystem( $optIn );
827
828 if ( ! $handedOff ) {
829 $this->logger->info(
830 'File hand-off not performed (or failed) — pending files will be cleaned up on OptIn deletion',
831 array(
832 'plugin' => 'double-opt-in',
833 'integration' => $this->getIdentifier(),
834 'optin_id' => $optIn->get_id(),
835 'file_count' => count( $files ),
836 )
837 );
838 return;
839 }
840
841 // Hand-off succeeded → integration now owns the files in its
842 // own DB. Delete our pending copies to avoid duplicate storage.
843 try {
844 $storage = new FileStorage( $this->logger );
845 $storage->deletePaths( $files );
846
847 // Clear the OptIn::files column so the cascade-delete on
848 // later OptIn removal doesn't try to re-unlink missing
849 // paths. Idempotent if save fails — pending dir is empty
850 // either way.
851 if ( method_exists( $optIn, 'set_files' ) ) {
852 $optIn->set_files( serialize( array() ) );
853 if ( method_exists( $optIn, 'save' ) ) {
854 $optIn->save();
855 }
856 }
857
858 $this->logger->info(
859 'Files handed off to form system + pending copies deleted',
860 array(
861 'plugin' => 'double-opt-in',
862 'integration' => $this->getIdentifier(),
863 'optin_id' => $optIn->get_id(),
864 'file_count' => count( $files ),
865 )
866 );
867 } catch ( \Throwable $e ) {
868 $this->logger->error(
869 'Hand-off succeeded but pending-cleanup failed',
870 array(
871 'plugin' => 'double-opt-in',
872 'error' => $e->getMessage(),
873 )
874 );
875 }
876 }
877
878 /**
879 * Allowed MIME types for file uploads stored with opt-in records.
880 */
881 private const ALLOWED_MIME_TYPES = array(
882 'jpg' => 'image/jpeg',
883 'jpeg' => 'image/jpeg',
884 'png' => 'image/png',
885 'gif' => 'image/gif',
886 'webp' => 'image/webp',
887 'pdf' => 'application/pdf',
888 'doc' => 'application/msword',
889 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
890 'txt' => 'text/plain',
891 'csv' => 'text/csv',
892 );
893
894 /**
895 * Copy and rename a file with a unique, non-guessable name.
896 *
897 * Validates the file MIME type against an allowlist before copying.
898 *
899 * @param string $file The source file path.
900 *
901 * @return string|null The new file path or null on failure/rejection.
902 */
903 private function copyAndRenameFile( string $file ): ?string {
904 $allowedMimes = apply_filters( 'f12_cf7_doubleoptin_allowed_mime_types', self::ALLOWED_MIME_TYPES );
905
906 $fileType = wp_check_filetype_and_ext( $file, wp_basename( $file ), $allowedMimes );
907
908 if ( empty( $fileType['type'] ) || empty( $fileType['ext'] ) ) {
909 $this->getLogger()->warning(
910 'File rejected: MIME type not allowed',
911 array(
912 'plugin' => 'double-opt-in',
913 'original' => $file,
914 )
915 );
916 return null;
917 }
918
919 $pathParts = explode( '/', $file );
920 array_pop( $pathParts );
921 $newName = bin2hex( random_bytes( 16 ) ) . '.' . $fileType['ext'];
922 $pathParts[] = $newName;
923 $newFile = implode( '/', $pathParts );
924
925 if ( copy( $file, $newFile ) ) {
926 $this->getLogger()->debug(
927 'File copied successfully',
928 array(
929 'plugin' => 'double-opt-in',
930 'original' => $file,
931 'new_file' => $newFile,
932 )
933 );
934 return $newFile;
935 }
936
937 $this->getLogger()->error(
938 'Failed to copy file',
939 array(
940 'plugin' => 'double-opt-in',
941 'original' => $file,
942 )
943 );
944
945 return null;
946 }
947
948 /**
949 * Validate and confirm an opt-in by hash.
950 *
951 * @param string $hash The opt-in hash.
952 *
953 * @return bool True if the opt-in was confirmed successfully.
954 */
955 public function validateOptIn( string $hash ): bool {
956 $optIn = OptIn::get_by_hash( $hash );
957
958 if ( ! $optIn ) {
959 $this->getLogger()->warning(
960 'OptIn not found for hash',
961 array(
962 'plugin' => 'double-opt-in',
963 'hash' => $hash,
964 )
965 );
966 self::setValidationStatus( 'not_found' );
967 return false;
968 }
969
970 // Check if this opt-in belongs to this integration
971 if ( ! $optIn->isType( $this->getIdentifier() ) ) {
972 return false;
973 }
974
975 // Check if the token has expired
976 $settings = CF7DoubleOptIn::getInstance()->getSettings();
977 $expiryHours = (int) ( $settings['token_expiry_hours'] ?? 48 );
978 if ( $expiryHours > 0 && ( time() - (int) $optIn->get_createtime() ) > ( $expiryHours * 3600 ) ) {
979 $this->getLogger()->info(
980 'OptIn token expired',
981 array(
982 'plugin' => 'double-opt-in',
983 'optin_id' => $optIn->get_id(),
984 'expiry_hours' => $expiryHours,
985 )
986 );
987 do_action( 'f12_cf7_doubleoptin_token_expired', $hash, $optIn );
988 self::setValidationStatus( 'expired' );
989 return false;
990 }
991
992 // Skip if already confirmed
993 if ( $optIn->is_confirmed() ) {
994 $this->getLogger()->info(
995 'OptIn already confirmed',
996 array(
997 'plugin' => 'double-opt-in',
998 'optin_id' => $optIn->get_id(),
999 )
1000 );
1001 do_action( 'f12_cf7_doubleoptin_already_confirmed', $hash, $optIn );
1002 self::setValidationStatus( 'already_confirmed' );
1003 return false;
1004 }
1005
1006 // Confirm the opt-in
1007 do_action( 'f12_cf7_doubleoptin_before_confirm', $hash, $optIn );
1008
1009 $optIn->set_doubleoptin( 1 );
1010 $optIn->set_updatetime( time() );
1011 $optIn->set_ipaddr_confirmation( IPHelper::getIPAdress() );
1012
1013 if ( ! $optIn->save() ) {
1014 $this->getLogger()->error(
1015 'Failed to confirm OptIn',
1016 array(
1017 'plugin' => 'double-opt-in',
1018 'optin_id' => $optIn->get_id(),
1019 )
1020 );
1021 return false;
1022 }
1023
1024 self::setValidationStatus( 'confirmed' );
1025
1026 // Track telemetry
1027 $telemetry = new Telemetry( $this->getLogger() );
1028 $telemetry->increment( 'confirmed_optins' );
1029
1030 // Dispatch event
1031 $this->dispatchOptInConfirmedEvent( $optIn, $hash );
1032
1033 do_action( 'f12_cf7_doubleoptin_after_confirm', $hash, $optIn );
1034
1035 // Send the original mail if enabled
1036 if ( apply_filters( 'f12_cf7_doubleoptin_send_default_mail', true, $optIn->get_cf_form_id() ) ) {
1037 do_action( 'f12_cf7_doubleoptin_before_send_default_mail', $optIn );
1038 $this->sendConfirmationMail( $optIn );
1039 do_action( 'f12_cf7_doubleoptin_after_send_default_mail', $optIn );
1040 }
1041
1042 $this->getLogger()->info(
1043 'OptIn confirmed successfully',
1044 array(
1045 'plugin' => 'double-opt-in',
1046 'optin_id' => $optIn->get_id(),
1047 )
1048 );
1049
1050 return true;
1051 }
1052
1053 /**
1054 * Remove stored files after processing.
1055 *
1056 * @param OptIn $optIn The opt-in record.
1057 *
1058 * @return void
1059 */
1060 public function removeStoredFiles( OptIn $optIn ): void {
1061 $files = maybe_unserialize( $optIn->get_files() );
1062
1063 if ( empty( $files ) || ! is_array( $files ) ) {
1064 return;
1065 }
1066
1067 foreach ( $files as $file ) {
1068 if ( empty( $file ) || ! is_file( $file ) ) {
1069 continue;
1070 }
1071
1072 if ( unlink( $file ) ) {
1073 $this->getLogger()->debug(
1074 'File removed successfully',
1075 array(
1076 'plugin' => 'double-opt-in',
1077 'file' => $file,
1078 )
1079 );
1080 } else {
1081 $this->getLogger()->warning(
1082 'Failed to remove file',
1083 array(
1084 'plugin' => 'double-opt-in',
1085 'file' => $file,
1086 )
1087 );
1088 }
1089 }
1090 }
1091
1092 /**
1093 * Disable spam protection hooks before sending confirmation mail.
1094 *
1095 * @return void
1096 */
1097 protected function beforeSendConfirmationMail(): void {
1098 // Disable CF7 validation for confirmation mail resend.
1099 // CF7 re-runs all form validations (required fields, quiz, acceptance checkboxes)
1100 // when creating a WPCF7_Submission instance. Since this is a confirmed opt-in
1101 // (not a real form submit), these validations must be bypassed.
1102 add_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1103 add_filter( 'wpcf7_spam', '__return_false', 0 );
1104 add_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1105
1106 // Disable CF7 Captcha if present
1107 add_filter( 'f12_cf7_captcha_is_installed_cf7', '__return_false', 999 );
1108
1109 // Remove reCAPTCHA filter
1110 remove_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9 );
1111
1112 // Remove hCaptcha validation filter
1113 $this->removeHCaptchaFilter();
1114
1115 $this->getLogger()->debug(
1116 'Validation and spam protection disabled for confirmation mail',
1117 array(
1118 'plugin' => 'double-opt-in',
1119 )
1120 );
1121 }
1122
1123 /**
1124 * Clear CF7 validation result to bypass field validation during confirmation mail.
1125 *
1126 * @param \WPCF7_Validation $result The validation result.
1127 *
1128 * @return \WPCF7_Validation A clean validation result with no errors.
1129 */
1130 public function clearValidationResult( $result ) {
1131 return new \WPCF7_Validation();
1132 }
1133
1134 /**
1135 * Re-enable spam protection hooks after sending confirmation mail.
1136 *
1137 * @return void
1138 */
1139 protected function afterSendConfirmationMail(): void {
1140 // Re-enable CF7 validation
1141 remove_filter( 'wpcf7_validate', array( $this, 'clearValidationResult' ), 999 );
1142 remove_filter( 'wpcf7_spam', '__return_false', 0 );
1143 remove_filter( 'wpcf7_skip_spam_check', '__return_true', 0 );
1144
1145 // Re-add reCAPTCHA filter
1146 if ( function_exists( 'wpcf7_recaptcha_verify_response' ) ) {
1147 add_filter( 'wpcf7_spam', 'wpcf7_recaptcha_verify_response', 9, 2 );
1148 }
1149
1150 // Re-add CF7 Captcha hooks
1151 if ( class_exists( '\forge12\contactform7\CF7Captcha\TimerValidatorCF7' ) ) {
1152 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\TimerValidatorCF7::isSpam', 100, 2 );
1153 add_filter( 'wpcf7_spam', '\forge12\contactform7\CF7Captcha\CF7IPLog::isSpam', 100, 2 );
1154 add_action( 'wpcf7_mail_sent', '\forge12\contactform7\CF7Captcha\CF7IPLog::doLogIP', 100, 1 );
1155 }
1156
1157 // Re-add hCaptcha validation filter
1158 $this->restoreHCaptchaFilter();
1159
1160 $this->getLogger()->debug(
1161 'Validation and spam protection re-enabled',
1162 array(
1163 'plugin' => 'double-opt-in',
1164 )
1165 );
1166 }
1167
1168 /**
1169 * Remove hCaptcha CF7 validation filter and store the instance for later restore.
1170 *
1171 * @return void
1172 */
1173 private function removeHCaptchaFilter(): void {
1174 if ( ! class_exists( '\HCaptcha\CF7\CF7' ) ) {
1175 return;
1176 }
1177
1178 global $wp_filter;
1179
1180 if ( ! isset( $wp_filter['wpcf7_validate'] ) ) {
1181 return;
1182 }
1183
1184 foreach ( $wp_filter['wpcf7_validate']->callbacks as $priority => $hooks ) {
1185 foreach ( $hooks as $key => $hook ) {
1186 if ( is_array( $hook['function'] ) && $hook['function'][0] instanceof \HCaptcha\CF7\CF7 ) {
1187 $this->hcaptchaCf7Instance = $hook['function'][0];
1188 $this->hcaptchaCf7Priority = $priority;
1189 remove_filter( 'wpcf7_validate', $hook['function'], $priority );
1190 $this->getLogger()->debug(
1191 'hCaptcha CF7 validation filter removed',
1192 array(
1193 'plugin' => 'double-opt-in',
1194 )
1195 );
1196
1197 return;
1198 }
1199 }
1200 }
1201 }
1202
1203 /**
1204 * Re-add hCaptcha CF7 validation filter if it was previously removed.
1205 *
1206 * @return void
1207 */
1208 private function restoreHCaptchaFilter(): void {
1209 if ( isset( $this->hcaptchaCf7Instance ) ) {
1210 add_filter( 'wpcf7_validate', array( $this->hcaptchaCf7Instance, 'verify_hcaptcha' ), $this->hcaptchaCf7Priority, 2 );
1211 $this->getLogger()->debug(
1212 'hCaptcha CF7 validation filter re-added',
1213 array(
1214 'plugin' => 'double-opt-in',
1215 )
1216 );
1217 unset( $this->hcaptchaCf7Instance, $this->hcaptchaCf7Priority );
1218 }
1219 }
1220
1221 /**
1222 * Dispatch FormSubmissionEvent.
1223 *
1224 * @param FormDataInterface $formData The form data.
1225 *
1226 * @return FormSubmissionEvent|null The event or null if dispatcher unavailable.
1227 */
1228 protected function dispatchFormSubmissionEvent( FormDataInterface $formData ): ?FormSubmissionEvent {
1229 try {
1230 $container = Container::getInstance();
1231 if ( $container->has( EventDispatcherInterface::class ) ) {
1232 $dispatcher = $container->get( EventDispatcherInterface::class );
1233 $event = new FormSubmissionEvent(
1234 $formData,
1235 $this->getIdentifier()
1236 );
1237 $dispatcher->dispatch( $event );
1238 return $event;
1239 }
1240 } catch ( \Exception $e ) {
1241 $this->getLogger()->warning(
1242 'Failed to dispatch FormSubmissionEvent',
1243 array(
1244 'plugin' => 'double-opt-in',
1245 'error' => $e->getMessage(),
1246 )
1247 );
1248 }
1249 return null;
1250 }
1251
1252 /**
1253 * Dispatch OptInCreatedEvent.
1254 *
1255 * @param OptIn $optIn The created opt-in.
1256 * @param FormDataInterface $formData The form data.
1257 *
1258 * @return void
1259 */
1260 protected function dispatchOptInCreatedEvent( OptIn $optIn, FormDataInterface $formData ): void {
1261 try {
1262 $container = Container::getInstance();
1263 if ( $container->has( EventDispatcherInterface::class ) ) {
1264 $dispatcher = $container->get( EventDispatcherInterface::class );
1265 $event = new OptInCreatedEvent(
1266 $optIn->get_id(),
1267 $formData->getFormId(),
1268 $this->getIdentifier(),
1269 $optIn->get_email(),
1270 $optIn->get_hash(),
1271 $formData->getFields()
1272 );
1273 $dispatcher->dispatch( $event );
1274 }
1275 } catch ( \Exception $e ) {
1276 $this->getLogger()->warning(
1277 'Failed to dispatch OptInCreatedEvent',
1278 array(
1279 'plugin' => 'double-opt-in',
1280 'error' => $e->getMessage(),
1281 )
1282 );
1283 }
1284 }
1285
1286 /**
1287 * Dispatch OptInConfirmedEvent.
1288 *
1289 * @param OptIn $optIn The confirmed opt-in.
1290 * @param string $hash The opt-in hash.
1291 *
1292 * @return void
1293 */
1294 protected function dispatchOptInConfirmedEvent( OptIn $optIn, string $hash ): void {
1295 try {
1296 $container = Container::getInstance();
1297 if ( $container->has( EventDispatcherInterface::class ) ) {
1298 $dispatcher = $container->get( EventDispatcherInterface::class );
1299
1300 $formData = maybe_unserialize( $optIn->get_content() );
1301
1302 $event = new OptInConfirmedEvent(
1303 $optIn->get_id(),
1304 $hash,
1305 $optIn->get_email(),
1306 $optIn->get_ipaddr_confirmation(),
1307 (int) $optIn->get_cf_form_id(),
1308 is_array( $formData ) ? $formData : array()
1309 );
1310 $dispatcher->dispatch( $event );
1311 }
1312 } catch ( \Exception $e ) {
1313 $this->getLogger()->warning(
1314 'Failed to dispatch OptInConfirmedEvent',
1315 array(
1316 'plugin' => 'double-opt-in',
1317 'error' => $e->getMessage(),
1318 )
1319 );
1320 }
1321 }
1322
1323 /**
1324 * Get the post type for this integration's forms.
1325 *
1326 * @since 4.1.0
1327 *
1328 * @return string The post type.
1329 */
1330 abstract protected function getPostType(): string;
1331
1332 /**
1333 * {@inheritdoc}
1334 */
1335 public function getForms(): array {
1336 $posts = get_posts(
1337 array(
1338 'post_type' => $this->getPostType(),
1339 'posts_per_page' => -1,
1340 'post_status' => 'publish',
1341 'orderby' => 'title',
1342 'order' => 'ASC',
1343 )
1344 );
1345
1346 $forms = array();
1347 foreach ( $posts as $post ) {
1348 $parameter = $this->getFormParameter( $post->ID );
1349 $forms[] = array(
1350 'id' => $post->ID,
1351 'title' => $post->post_title,
1352 'integration' => $this->getIdentifier(),
1353 'enabled' => (int) ( $parameter['enable'] ?? 0 ) === 1,
1354 'edit_url' => $this->getFormEditUrl( $post->ID ),
1355 );
1356 }
1357
1358 $this->getLogger()->debug(
1359 'Retrieved forms for integration',
1360 array(
1361 'plugin' => 'double-opt-in',
1362 'integration' => $this->getIdentifier(),
1363 'count' => count( $forms ),
1364 )
1365 );
1366
1367 return $forms;
1368 }
1369
1370 /**
1371 * {@inheritdoc}
1372 */
1373 public function getFormTitle( $formId ): string {
1374 $post = get_post( (int) $formId );
1375 return $post ? $post->post_title : '';
1376 }
1377
1378 /**
1379 * {@inheritdoc}
1380 */
1381 public function getFormEditUrl( $formId ): string {
1382 return get_edit_post_link( (int) $formId, 'raw' ) ?: '';
1383 }
1384 }
1385