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 / Admin / FormSettingsController.php

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

443 lines 12.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Form Settings Controller
4 *
5 * @package Forge12\DoubleOptIn\Admin
6 * @since 4.1.0
7 */
8
9 namespace Forge12\DoubleOptIn\Admin;
10
11 use Forge12\DoubleOptIn\FormSettings\FormSettingsDTO;
12 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
13 use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator;
14 use Forge12\Shared\LoggerInterface;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 /**
21 * Class FormSettingsController
22 *
23 * Handles AJAX requests for form settings management.
24 */
25 class FormSettingsController {
26
27 /**
28 * Logger instance.
29 *
30 * @var LoggerInterface
31 */
32 private LoggerInterface $logger;
33
34 /**
35 * Form settings service.
36 *
37 * @var FormSettingsService
38 */
39 private FormSettingsService $service;
40
41 /**
42 * Form settings validator.
43 *
44 * @var FormSettingsValidator
45 */
46 private FormSettingsValidator $validator;
47
48 /**
49 * Constructor.
50 *
51 * @param LoggerInterface $logger The logger instance.
52 * @param FormSettingsService $service The settings service.
53 * @param FormSettingsValidator $validator The validator.
54 */
55 public function __construct(
56 LoggerInterface $logger,
57 FormSettingsService $service,
58 FormSettingsValidator $validator
59 ) {
60 $this->logger = $logger;
61 $this->service = $service;
62 $this->validator = $validator;
63 }
64
65 /**
66 * Register AJAX actions.
67 *
68 * @return void
69 */
70 public function registerActions(): void {
71 add_action( 'wp_ajax_doi_get_form_settings', array( $this, 'getFormSettings' ) );
72 add_action( 'wp_ajax_doi_save_form_settings', array( $this, 'saveFormSettings' ) );
73 add_action( 'wp_ajax_doi_toggle_form', array( $this, 'toggleForm' ) );
74 add_action( 'wp_ajax_doi_get_all_forms', array( $this, 'getAllForms' ) );
75 }
76
77 /**
78 * Get settings for a specific form.
79 *
80 * @return void
81 */
82 public function getFormSettings(): void {
83 $this->verifyNonce();
84 $this->checkCapability();
85
86 // Keep as string to support composite IDs (e.g., Elementor: "123_abc456")
87 $formId = isset( $_POST['form_id'] ) ? sanitize_text_field( $_POST['form_id'] ) : '';
88 $integration = isset( $_POST['integration'] ) ? sanitize_text_field( $_POST['integration'] ) : '';
89
90 if ( empty( $formId ) ) {
91 $this->sendError( __( 'Invalid form ID.', 'double-opt-in' ) );
92 }
93
94 $formData = $this->service->getFormData( $formId, $integration );
95
96 if ( ! $formData ) {
97 $this->sendError( __( 'Form not found.', 'double-opt-in' ) );
98 }
99
100 // Add additional data for the panel
101 $formData['templates'] = $this->service->getAvailableTemplates( $formId );
102 $formData['categories'] = $this->service->getAvailableCategories();
103 $formData['pages'] = $this->service->getAvailablePages();
104 $formData['templateDetails'] = $this->service->getTemplateDetails();
105
106 /**
107 * Filter to allow extensions (e.g., Pro version) to add additional data to the form settings response.
108 *
109 * @param array $formData The form data array.
110 * @param int $formId The form ID.
111 *
112 * @since 4.1.0
113 */
114 $formData = apply_filters( 'f12_doi_form_settings_data', $formData, $formId );
115
116 $this->sendSuccess( $formData );
117 }
118
119 /**
120 * Save settings for a specific form.
121 *
122 * @return void
123 */
124 public function saveFormSettings(): void {
125 $this->verifyNonce();
126 $this->checkCapability();
127
128 // Keep as string to support composite IDs (e.g., Elementor: "123_abc456")
129 $formId = isset( $_POST['form_id'] ) ? sanitize_text_field( $_POST['form_id'] ) : '';
130
131 if ( empty( $formId ) ) {
132 $this->sendError( __( 'Invalid form ID.', 'double-opt-in' ) );
133 }
134
135 // For composite IDs, extract the post ID for storage
136 $storageId = strpos( $formId, '_' ) !== false ? (int) explode( '_', $formId )[0] : (int) $formId;
137
138 // Parse settings from POST data
139 $settingsData = isset( $_POST['settings'] ) ? $_POST['settings'] : array();
140
141 if ( is_string( $settingsData ) ) {
142 $settingsData = json_decode( wp_unslash( $settingsData ), true );
143 }
144
145 // Capture current state BEFORE sanitize so we can detect
146 // enabled-state transitions for the completeness-gate (plan §2.2).
147 $oldSettings = $this->service->getSettings( $storageId );
148 $wasEnabled = $oldSettings->enabled;
149
150 // Sanitize and create DTO
151 $settings = $this->validator->sanitize( $settingsData );
152
153 // Completeness-gate (plan/doi-completeness-gate.md §2.2) — runs
154 // BEFORE the format-validator so we surface the more specific
155 // INCOMPLETE_CONFIG error code on the explicit enable-attempt
156 // path. Auto-disable applies on the silent-drift path (was
157 // enabled, save makes it incomplete).
158 $missingRequired = $settings->getMissingRequiredFields();
159 $autoDisabled = false;
160
161 if ( $settings->enabled && ! empty( $missingRequired ) ) {
162 if ( ! $wasEnabled ) {
163 // Explicit enable-attempt with incomplete config — block.
164 $this->sendIncompleteConfigError( $missingRequired );
165 return; // sendIncompleteConfigError() exits via wp_send_json
166 }
167
168 // Was enabled, save makes it incomplete — auto-disable rather
169 // than block, so the user's other edits aren't lost. Surface
170 // via the response so the UI can render a notification.
171 $settings->enabled = false;
172 $autoDisabled = true;
173
174 /**
175 * Fired when a form save would have left the form enabled with
176 * an incomplete config, and the controller auto-disabled it
177 * instead.
178 *
179 * @since 4.5.0
180 *
181 * @param string $formId The form ID (composite for Elementor).
182 * @param array $missing The list of missing required-field IDs.
183 */
184 do_action( 'f12_doi_form_auto_disabled_incomplete', $formId, $missingRequired );
185
186 $this->logger->warning(
187 'Form auto-disabled — save would have left it enabled with incomplete config',
188 array(
189 'plugin' => 'double-opt-in',
190 'form_id' => $formId,
191 'missing' => $missingRequired,
192 )
193 );
194 }
195
196 // Validate (format-only checks remaining after completeness-gate)
197 $errors = $this->validator->validate( $settings );
198 if ( ! empty( $errors ) ) {
199 $this->sendError( __( 'Validation failed.', 'double-opt-in' ), $errors );
200 }
201
202 /**
203 * Filter to allow extensions (e.g., Pro version) to modify settings before saving.
204 *
205 * @param FormSettingsDTO $settings The settings DTO.
206 * @param int $storageId The storage ID (post ID for settings).
207 * @param array $settingsData The raw settings data from POST.
208 *
209 * @since 4.1.0
210 */
211 $settings = apply_filters( 'f12_doi_form_settings_before_save', $settings, $storageId, $settingsData );
212
213 // Save using the storage ID (post ID for composite IDs)
214 $result = $this->service->saveSettings( $storageId, $settings );
215
216 if ( ! $result ) {
217 $this->sendError( __( 'Failed to save settings.', 'double-opt-in' ) );
218 }
219
220 // Detect integration type from form ID format
221 $integration = '';
222 if ( strpos( $formId, '_' ) !== false ) {
223 $integration = 'elementor';
224 }
225
226 /**
227 * Action fired after form settings have been saved.
228 * Allows extensions (e.g., Pro version) to save additional data.
229 *
230 * @param string $formId The form ID (can be composite for Elementor).
231 * @param FormSettingsDTO $settings The settings DTO.
232 * @param array $settingsData The raw settings data from POST.
233 * @param string $integration The integration identifier (e.g., 'elementor').
234 *
235 * @since 4.1.0
236 */
237 do_action( 'f12_doi_form_settings_saved', $formId, $settings, $settingsData, $integration );
238
239 $this->logger->info(
240 'Form settings saved via AJAX',
241 array(
242 'plugin' => 'double-opt-in',
243 'form_id' => $formId,
244 )
245 );
246
247 $this->sendSuccess(
248 array(
249 'message' => $autoDisabled
250 ? __( 'Settings saved. Double Opt-In was auto-disabled because the configuration is incomplete.', 'double-opt-in' )
251 : __( 'Settings saved successfully.', 'double-opt-in' ),
252 'enabled' => $settings->enabled,
253 'autoDisabled' => $autoDisabled,
254 'missing' => array_values( $missingRequired ),
255 )
256 );
257 }
258
259 /**
260 * Toggle the enabled state of a form.
261 *
262 * @return void
263 */
264 public function toggleForm(): void {
265 $this->verifyNonce();
266 $this->checkCapability();
267
268 // Keep as string to support composite IDs (e.g., Elementor: "123_abc456")
269 $formId = isset( $_POST['form_id'] ) ? sanitize_text_field( $_POST['form_id'] ) : '';
270
271 if ( empty( $formId ) ) {
272 $this->sendError( __( 'Invalid form ID.', 'double-opt-in' ) );
273 }
274
275 // For composite IDs, extract the post ID for storage
276 $storageId = strpos( $formId, '_' ) !== false ? (int) explode( '_', $formId )[0] : (int) $formId;
277
278 // Completeness-gate before toggle-to-enabled (plan §2.3).
279 // Toggling-to-disabled is always allowed; toggling-to-enabled is
280 // blocked when the config is incomplete.
281 $currentSettings = $this->service->getSettings( $storageId );
282 if ( ! $currentSettings->enabled ) {
283 $missing = $currentSettings->getMissingRequiredFields();
284 if ( ! empty( $missing ) ) {
285 $this->sendIncompleteConfigError( $missing );
286 return;
287 }
288 }
289
290 $newState = $this->service->toggleEnabled( $storageId );
291
292 // Detect integration type from form ID format
293 $integration = '';
294 if ( strpos( $formId, '_' ) !== false ) {
295 $integration = 'elementor';
296 }
297
298 /**
299 * Action fired after form DOI state has been toggled.
300 * Allows extensions to sync the state with the form system (e.g., Elementor submit_actions).
301 *
302 * @param string $formId The form ID (can be composite for Elementor).
303 * @param bool $enabled The new enabled state.
304 * @param string $integration The integration identifier (e.g., 'elementor').
305 *
306 * @since 4.1.0
307 */
308 do_action( 'f12_doi_form_toggled', $formId, $newState, $integration );
309
310 $this->logger->info(
311 'Form toggle via AJAX',
312 array(
313 'plugin' => 'double-opt-in',
314 'form_id' => $formId,
315 'storage_id' => $storageId,
316 'enabled' => $newState,
317 )
318 );
319
320 $this->sendSuccess(
321 array(
322 'enabled' => $newState,
323 'message' => $newState
324 ? __( 'Double Opt-In enabled.', 'double-opt-in' )
325 : __( 'Double Opt-In disabled.', 'double-opt-in' ),
326 )
327 );
328 }
329
330 /**
331 * Get all forms from all integrations.
332 *
333 * @return void
334 */
335 public function getAllForms(): void {
336 $this->verifyNonce();
337 $this->checkCapability();
338
339 $forms = $this->service->getAllForms();
340
341 $this->sendSuccess(
342 array(
343 'forms' => $forms,
344 )
345 );
346 }
347
348 /**
349 * Verify the AJAX nonce.
350 *
351 * @return void
352 */
353 private function verifyNonce(): void {
354 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'doi_form_settings' ) ) {
355 $this->logger->warning(
356 'Nonce verification failed',
357 array(
358 'plugin' => 'double-opt-in',
359 )
360 );
361 $this->sendError( __( 'Security check failed.', 'double-opt-in' ), array(), 403 );
362 }
363 }
364
365 /**
366 * Check user capability.
367 *
368 * @return void
369 */
370 private function checkCapability(): void {
371 if ( ! current_user_can( 'manage_options' ) ) {
372 $this->logger->warning(
373 'Unauthorized access attempt',
374 array(
375 'plugin' => 'double-opt-in',
376 )
377 );
378 $this->sendError( __( 'You do not have permission to perform this action.', 'double-opt-in' ), array(), 403 );
379 }
380 }
381
382 /**
383 * Send a success response.
384 *
385 * @param array $data The response data.
386 *
387 * @return void
388 */
389 private function sendSuccess( array $data ): void {
390 wp_send_json_success( $data );
391 }
392
393 /**
394 * Send an error response.
395 *
396 * @param string $message The error message.
397 * @param array $errors Additional error details.
398 * @param int $statusCode HTTP status code.
399 *
400 * @return void
401 */
402 private function sendError( string $message, array $errors = array(), int $statusCode = 400 ): void {
403 wp_send_json_error(
404 array(
405 'message' => $message,
406 'errors' => $errors,
407 ),
408 $statusCode
409 );
410 }
411
412 /**
413 * Send a structured INCOMPLETE_CONFIG error response for the
414 * completeness-gate (plan/doi-completeness-gate.md §2.2 + §2.3).
415 *
416 * Distinct from {@see sendError()} because the React UI needs to
417 * distinguish "this save was rejected because the form is missing
418 * required fields" from "this save had a generic validation error"
419 * — the former triggers the Toast + "open configuration" CTA from
420 * §2.7, the latter falls through to inline field-level errors.
421 *
422 * Status 422 (Unprocessable Entity): the request was syntactically
423 * valid but semantically incomplete.
424 *
425 * @param array<int,string> $missing Stable required-field IDs
426 * ('recipient', 'subject',
427 * 'body_or_template', addon-contributed).
428 */
429 private function sendIncompleteConfigError( array $missing ): void {
430 wp_send_json_error(
431 array(
432 'code' => 'INCOMPLETE_CONFIG',
433 'message' => __(
434 'Cannot enable Double Opt-In: configuration is incomplete. Please fill in all required fields first.',
435 'double-opt-in'
436 ),
437 'missing' => array_values( $missing ),
438 ),
439 422
440 );
441 }
442 }
443