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 / Frontend / ErrorNotification.php

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

317 lines 8.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Universal Error Notification System
4 *
5 * Provides a form-plugin-agnostic mechanism to display OptIn creation errors
6 * to the user via an AJAX endpoint and frontend JavaScript notification.
7 *
8 * @package Forge12\DoubleOptIn\Frontend
9 * @since 4.2.0
10 */
11
12 namespace Forge12\DoubleOptIn\Frontend;
13
14 use Forge12\DoubleOptIn\Integration\OptInError;
15 use forge12\contactform7\CF7DoubleOptIn\IPHelper;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Class ErrorNotification
23 *
24 * Stores OptIn errors in a short-lived transient keyed by client fingerprint
25 * and exposes an AJAX endpoint for the frontend JS to retrieve them.
26 *
27 * Flow:
28 * 1. createOptIn() fails → AbstractFormIntegration calls ErrorNotification::store()
29 * 2. Form plugin shows its own response (success or generic error)
30 * 3. Frontend JS detects form submission completed
31 * 4. JS calls AJAX endpoint `doi_check_submission_error`
32 * 5. Endpoint returns the error (if any) and deletes the transient
33 * 6. JS displays a notification overlay
34 */
35 class ErrorNotification {
36
37 /**
38 * Transient prefix for error storage.
39 */
40 private const TRANSIENT_PREFIX = 'doi_error_';
41
42 /**
43 * Transient prefix for success-confirmation storage.
44 *
45 * Parallel to the error store: integrations that die() early and
46 * bypass the form plugin's own confirmation message (Avada) stash the
47 * "opt-in email sent" message here so the frontend toast can show it.
48 */
49 private const SUCCESS_TRANSIENT_PREFIX = 'doi_success_';
50
51 /**
52 * How long the error transient lives (in seconds).
53 */
54 private const TRANSIENT_TTL = 60;
55
56 /**
57 * Register WordPress hooks.
58 *
59 * @return void
60 */
61 public function register(): void {
62 add_action( 'wp_ajax_doi_check_submission_error', array( $this, 'handleAjax' ) );
63 add_action( 'wp_ajax_nopriv_doi_check_submission_error', array( $this, 'handleAjax' ) );
64 add_action( 'wp_enqueue_scripts', array( $this, 'enqueueAssets' ) );
65 }
66
67 /**
68 * Store an OptInError for later retrieval by the frontend.
69 *
70 * @param OptInError $error The error to store.
71 * @param int $formId The form ID that caused the error.
72 *
73 * @return void
74 */
75 public static function store( OptInError $error, int $formId ): void {
76 $key = self::getTransientKey();
77
78 set_transient(
79 $key,
80 array(
81 'code' => $error->getCode(),
82 'message' => $error->getMessage(),
83 'form_id' => $formId,
84 'time' => time(),
85 'hide_confirmation' => (bool) apply_filters( 'f12_cf7_doubleoptin_show_validation_error', false ),
86 ),
87 self::TRANSIENT_TTL
88 );
89 }
90
91 /**
92 * Retrieve and delete the stored error for the current client.
93 *
94 * @return array|null The error data or null if none exists.
95 */
96 public static function retrieve(): ?array {
97 $key = self::getTransientKey();
98 $data = get_transient( $key );
99
100 if ( ! is_array( $data ) || empty( $data['code'] ) ) {
101 return null;
102 }
103
104 delete_transient( $key );
105
106 return $data;
107 }
108
109 /**
110 * Store a success-confirmation message for later retrieval by the
111 * frontend. Used by integrations (Avada) that terminate the request
112 * early and therefore skip the form plugin's own "sent" message.
113 *
114 * @param string $message The confirmation message to show.
115 * @param int $formId The form ID the opt-in came from.
116 *
117 * @return void
118 */
119 public static function storeSuccess( string $message, int $formId ): void {
120 set_transient(
121 self::getSuccessTransientKey(),
122 array(
123 'message' => $message,
124 'form_id' => $formId,
125 'time' => time(),
126 ),
127 self::TRANSIENT_TTL
128 );
129 }
130
131 /**
132 * Retrieve and delete the stored success message for the current client.
133 *
134 * @return array|null The success data or null if none exists.
135 */
136 public static function retrieveSuccess(): ?array {
137 $key = self::getSuccessTransientKey();
138 $data = get_transient( $key );
139
140 if ( ! is_array( $data ) || empty( $data['message'] ) ) {
141 return null;
142 }
143
144 delete_transient( $key );
145
146 return $data;
147 }
148
149 /**
150 * AJAX handler: check for a stored submission error.
151 *
152 * @return void
153 */
154 public function handleAjax(): void {
155 check_ajax_referer( 'doi_error_notification', 'nonce' );
156
157 $data = self::retrieve();
158
159 if ( ! $data ) {
160 // No error stored — surface a success confirmation if one was
161 // stashed by an integration that die()s early (Avada). Other
162 // integrations show their own message and never store one, so
163 // this stays empty for them (no duplicate toast).
164 $success = self::retrieveSuccess();
165 wp_send_json_success(
166 array(
167 'error' => null,
168 'success_message' => is_array( $success ) ? $success['message'] : null,
169 )
170 );
171 return;
172 }
173
174 // Allow message customization via the same filter used by integrations
175 $optInError = OptInError::fromCode( $data['code'] );
176 $message = apply_filters(
177 'f12_cf7_doubleoptin_error_message',
178 $data['message'],
179 $optInError,
180 $data['form_id']
181 );
182
183 $response = array(
184 'error' => array(
185 'code' => $data['code'],
186 'message' => $message,
187 'hide_confirmation' => ! empty( $data['hide_confirmation'] ),
188 ),
189 );
190
191 // Add redirect URL if configured for this form
192 if ( ! empty( $data['form_id'] ) ) {
193 $redirectUrl = self::getErrorRedirectUrl( (int) $data['form_id'], $data['code'] );
194 if ( $redirectUrl ) {
195 $response['redirect_url'] = $redirectUrl;
196 }
197 }
198
199 wp_send_json_success( $response );
200 }
201
202 /**
203 * Enqueue frontend assets on pages that may contain forms.
204 *
205 * The universal error notification is enabled by default via the
206 * `f12_cf7_doubleoptin_enable_error_notification` filter (default: true).
207 * This is independent of the `f12_cf7_doubleoptin_show_validation_error`
208 * filter which controls native per-plugin error display.
209 *
210 * @return void
211 */
212 public function enqueueAssets(): void {
213 if ( ! apply_filters( 'f12_cf7_doubleoptin_enable_error_notification', true ) ) {
214 return;
215 }
216
217 wp_enqueue_style(
218 'doi-error-notification',
219 plugins_url( 'core/assets/doi-error-notification.css', F12_DOUBLEOPTIN_PLUGIN_FILE ),
220 array(),
221 defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '4.2.0'
222 );
223
224 wp_enqueue_script(
225 'doi-error-notification',
226 plugins_url( 'core/assets/doi-error-notification.js', F12_DOUBLEOPTIN_PLUGIN_FILE ),
227 array(),
228 defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '4.2.0',
229 true
230 );
231
232 wp_localize_script(
233 'doi-error-notification',
234 'doiErrorNotification',
235 array(
236 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
237 'nonce' => wp_create_nonce( 'doi_error_notification' ),
238 )
239 );
240 }
241
242 /**
243 * Get the error redirect URL for a given form.
244 *
245 * Looks up the form's error_page setting from post_meta and builds
246 * a redirect URL with the error code as a query parameter.
247 *
248 * @param int $formId The form post ID.
249 * @param string $errorCode The error code (e.g. 'rate_limit_ip').
250 *
251 * @return string The redirect URL, or empty string if not configured.
252 */
253 private static function getErrorRedirectUrl( int $formId, string $errorCode ): string {
254 $meta = get_post_meta( $formId, 'f12-cf7-doubleoptin', true );
255
256 if ( empty( $meta ) || ! is_array( $meta ) ) {
257 return '';
258 }
259
260 // Unique email duplicate → check dedicated redirect page first
261 if ( $errorCode === OptInError::UNIQUE_EMAIL_DUPLICATE ) {
262 $uePageId = (int) ( $meta['unique_email_redirect_page'] ?? -1 );
263 if ( $uePageId > 0 ) {
264 $permalink = get_permalink( $uePageId );
265 if ( $permalink ) {
266 return add_query_arg( 'doi_error', sanitize_key( $errorCode ), $permalink );
267 }
268 }
269 }
270
271 // Fallback to general error_page
272 $pageId = (int) ( $meta['error_page'] ?? -1 );
273
274 if ( $pageId <= 0 ) {
275 return '';
276 }
277
278 $permalink = get_permalink( $pageId );
279
280 if ( ! $permalink ) {
281 return '';
282 }
283
284 return add_query_arg( 'doi_error', sanitize_key( $errorCode ), $permalink );
285 }
286
287 /**
288 * Build the transient key for the current client.
289 *
290 * Uses IP + User-Agent to identify the client without cookies or sessions.
291 * The key is short-lived (60s) so collision risk is negligible.
292 *
293 * @return string The transient key.
294 */
295 public static function getTransientKey(): string {
296 $ip = class_exists( IPHelper::class ) ? IPHelper::getIPAdress() : ( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
297 $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
298
299 return self::TRANSIENT_PREFIX . md5( $ip . '|' . $userAgent );
300 }
301
302 /**
303 * Build the success-confirmation transient key for the current client.
304 *
305 * Same client fingerprint as {@see self::getTransientKey()} but a
306 * distinct prefix so a stored success and a stored error never collide.
307 *
308 * @return string The transient key.
309 */
310 public static function getSuccessTransientKey(): string {
311 $ip = class_exists( IPHelper::class ) ? IPHelper::getIPAdress() : ( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
312 $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
313
314 return self::SUCCESS_TRANSIENT_PREFIX . md5( $ip . '|' . $userAgent );
315 }
316 }
317