PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.3
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.3
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 / Frontend / ErrorNotification.php

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

330 lines 9.2 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' => $error->shouldShowToVisitor( $formId ),
86 ),
87 self::TRANSIENT_TTL
88 );
89 }
90
91 /**
92 * Drop the stored error for the current client.
93 *
94 * For integrations that already put the message into the form's own
95 * response (CF7 abort, Elementor error). Without this the toast shows
96 * the same sentence a second time next to it.
97 *
98 * @return void
99 */
100 public static function forget(): void {
101 delete_transient( self::getTransientKey() );
102 }
103
104 /**
105 * Retrieve and delete the stored error for the current client.
106 *
107 * @return array|null The error data or null if none exists.
108 */
109 public static function retrieve(): ?array {
110 $key = self::getTransientKey();
111 $data = get_transient( $key );
112
113 if ( ! is_array( $data ) || empty( $data['code'] ) ) {
114 return null;
115 }
116
117 delete_transient( $key );
118
119 return $data;
120 }
121
122 /**
123 * Store a success-confirmation message for later retrieval by the
124 * frontend. Used by integrations (Avada) that terminate the request
125 * early and therefore skip the form plugin's own "sent" message.
126 *
127 * @param string $message The confirmation message to show.
128 * @param int $formId The form ID the opt-in came from.
129 *
130 * @return void
131 */
132 public static function storeSuccess( string $message, int $formId ): void {
133 set_transient(
134 self::getSuccessTransientKey(),
135 array(
136 'message' => $message,
137 'form_id' => $formId,
138 'time' => time(),
139 ),
140 self::TRANSIENT_TTL
141 );
142 }
143
144 /**
145 * Retrieve and delete the stored success message for the current client.
146 *
147 * @return array|null The success data or null if none exists.
148 */
149 public static function retrieveSuccess(): ?array {
150 $key = self::getSuccessTransientKey();
151 $data = get_transient( $key );
152
153 if ( ! is_array( $data ) || empty( $data['message'] ) ) {
154 return null;
155 }
156
157 delete_transient( $key );
158
159 return $data;
160 }
161
162 /**
163 * AJAX handler: check for a stored submission error.
164 *
165 * @return void
166 */
167 public function handleAjax(): void {
168 check_ajax_referer( 'doi_error_notification', 'nonce' );
169
170 $data = self::retrieve();
171
172 if ( ! $data ) {
173 // No error stored — surface a success confirmation if one was
174 // stashed by an integration that die()s early (Avada). Other
175 // integrations show their own message and never store one, so
176 // this stays empty for them (no duplicate toast).
177 $success = self::retrieveSuccess();
178 wp_send_json_success(
179 array(
180 'error' => null,
181 'success_message' => is_array( $success ) ? $success['message'] : null,
182 )
183 );
184 return;
185 }
186
187 // Allow message customization via the same filter used by integrations
188 $optInError = OptInError::fromCode( $data['code'] );
189 $message = apply_filters(
190 'f12_cf7_doubleoptin_error_message',
191 $data['message'],
192 $optInError,
193 $data['form_id']
194 );
195
196 $response = array(
197 'error' => array(
198 'code' => $data['code'],
199 'message' => $message,
200 'hide_confirmation' => ! empty( $data['hide_confirmation'] ),
201 ),
202 );
203
204 // Add redirect URL if configured for this form
205 if ( ! empty( $data['form_id'] ) ) {
206 $redirectUrl = self::getErrorRedirectUrl( (int) $data['form_id'], $data['code'] );
207 if ( $redirectUrl ) {
208 $response['redirect_url'] = $redirectUrl;
209 }
210 }
211
212 wp_send_json_success( $response );
213 }
214
215 /**
216 * Enqueue frontend assets on pages that may contain forms.
217 *
218 * The universal error notification is enabled by default via the
219 * `f12_cf7_doubleoptin_enable_error_notification` filter (default: true).
220 * This is independent of the `f12_cf7_doubleoptin_show_validation_error`
221 * filter which controls native per-plugin error display.
222 *
223 * @return void
224 */
225 public function enqueueAssets(): void {
226 if ( ! apply_filters( 'f12_cf7_doubleoptin_enable_error_notification', true ) ) {
227 return;
228 }
229
230 wp_enqueue_style(
231 'doi-error-notification',
232 plugins_url( 'core/assets/doi-error-notification.css', F12_DOUBLEOPTIN_PLUGIN_FILE ),
233 array(),
234 defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '4.2.0'
235 );
236
237 wp_enqueue_script(
238 'doi-error-notification',
239 plugins_url( 'core/assets/doi-error-notification.js', F12_DOUBLEOPTIN_PLUGIN_FILE ),
240 array(),
241 defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '4.2.0',
242 true
243 );
244
245 wp_localize_script(
246 'doi-error-notification',
247 'doiErrorNotification',
248 array(
249 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
250 'nonce' => wp_create_nonce( 'doi_error_notification' ),
251 )
252 );
253 }
254
255 /**
256 * Get the error redirect URL for a given form.
257 *
258 * Looks up the form's error_page setting from post_meta and builds
259 * a redirect URL with the error code as a query parameter.
260 *
261 * @param int $formId The form post ID.
262 * @param string $errorCode The error code (e.g. 'rate_limit_ip').
263 *
264 * @return string The redirect URL, or empty string if not configured.
265 */
266 private static function getErrorRedirectUrl( int $formId, string $errorCode ): string {
267 $meta = get_post_meta( $formId, 'f12-cf7-doubleoptin', true );
268
269 if ( empty( $meta ) || ! is_array( $meta ) ) {
270 return '';
271 }
272
273 // Unique email duplicate → check dedicated redirect page first
274 if ( $errorCode === OptInError::UNIQUE_EMAIL_DUPLICATE ) {
275 $uePageId = (int) ( $meta['unique_email_redirect_page'] ?? -1 );
276 if ( $uePageId > 0 ) {
277 $permalink = get_permalink( $uePageId );
278 if ( $permalink ) {
279 return add_query_arg( 'doi_error', sanitize_key( $errorCode ), $permalink );
280 }
281 }
282 }
283
284 // Fallback to general error_page
285 $pageId = (int) ( $meta['error_page'] ?? -1 );
286
287 if ( $pageId <= 0 ) {
288 return '';
289 }
290
291 $permalink = get_permalink( $pageId );
292
293 if ( ! $permalink ) {
294 return '';
295 }
296
297 return add_query_arg( 'doi_error', sanitize_key( $errorCode ), $permalink );
298 }
299
300 /**
301 * Build the transient key for the current client.
302 *
303 * Uses IP + User-Agent to identify the client without cookies or sessions.
304 * The key is short-lived (60s) so collision risk is negligible.
305 *
306 * @return string The transient key.
307 */
308 public static function getTransientKey(): string {
309 $ip = class_exists( IPHelper::class ) ? IPHelper::getIPAdress() : ( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
310 $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
311
312 return self::TRANSIENT_PREFIX . md5( $ip . '|' . $userAgent );
313 }
314
315 /**
316 * Build the success-confirmation transient key for the current client.
317 *
318 * Same client fingerprint as {@see self::getTransientKey()} but a
319 * distinct prefix so a stored success and a stored error never collide.
320 *
321 * @return string The transient key.
322 */
323 public static function getSuccessTransientKey(): string {
324 $ip = class_exists( IPHelper::class ) ? IPHelper::getIPAdress() : ( $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0' );
325 $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
326
327 return self::SUCCESS_TRANSIENT_PREFIX . md5( $ip . '|' . $userAgent );
328 }
329 }
330