PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / trunk
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification vtrunk
5.7.0 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 All 39 releases
double-opt-in / src / Setup / SetupService.php

SetupService.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification trunk, at src/Setup/SetupService.php

582 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * What the setup wizard reads and writes.
4 *
5 * Four steps (sender, forms, mail, page), each saved as it is confirmed, and a
6 * finish that turns the collected values into form settings. Forms that are
7 * already configured are left alone: the wizard only switches on forms that
8 * have no Double Opt-In yet.
9 *
10 * @package Forge12\DoubleOptIn\Setup
11 * @since 5.7.0
12 */
13
14 declare( strict_types=1 );
15
16 namespace Forge12\DoubleOptIn\Setup;
17
18 use Forge12\DoubleOptIn\EmailTemplates\PlaceholderMapper;
19 use Forge12\DoubleOptIn\FormSettings\FormSettingsService;
20 use Forge12\DoubleOptIn\Integration\FormIntegrationRegistry;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 class SetupService {
27
28 public const STEP_SENDER = 'sender';
29 public const STEP_FORMS = 'forms';
30 public const STEP_MAIL = 'mail';
31 public const STEP_PAGE = 'page';
32
33 /** Step name => zero-based position. */
34 public const STEPS = array(
35 self::STEP_SENDER => 0,
36 self::STEP_FORMS => 1,
37 self::STEP_MAIL => 2,
38 self::STEP_PAGE => 3,
39 );
40
41 public const TEST_MAIL_LIMIT = 5;
42 public const TEST_MAIL_WINDOW = 3600;
43
44 /** @var SetupState */
45 private $state;
46
47 /** @var FormSettingsService */
48 private $settings;
49
50 /** @var FormPluginDetector */
51 private $detector;
52
53 /** @var SetupMailComposer */
54 private $composer;
55
56 /** @var callable():array<int, array{id:int, title:string, enabled:bool}> */
57 private $cf7Forms;
58
59 /** @var callable(int):string[] */
60 private $cf7Fields;
61
62 /** @var callable():array<int, array{id:int, title:string}> */
63 private $pages;
64
65 /**
66 * @param callable|null $cf7Forms Lists CF7 forms (test seam).
67 * @param callable|null $cf7Fields Lists the field names of a CF7 form (test seam).
68 * @param callable|null $pages Lists published pages (test seam).
69 */
70 public function __construct(
71 SetupState $state,
72 FormSettingsService $settings,
73 FormPluginDetector $detector,
74 SetupMailComposer $composer,
75 ?callable $cf7Forms = null,
76 ?callable $cf7Fields = null,
77 ?callable $pages = null
78 ) {
79 $this->state = $state;
80 $this->settings = $settings;
81 $this->detector = $detector;
82 $this->composer = $composer;
83 $this->cf7Forms = $cf7Forms ?? array( self::class, 'listCf7Forms' );
84 $this->cf7Fields = $cf7Fields ?? array( self::class, 'listCf7Fields' );
85 $this->pages = $pages ?? array( self::class, 'listPublishedPages' );
86 }
87
88 /**
89 * Everything the wizard needs to render, with prefilled values.
90 *
91 * @return array<string, mixed>
92 */
93 public function overview(): array {
94 $state = $this->state->get();
95 $draft = $state['draft'];
96 $defaults = FormDefaults::get();
97
98 $forms = array();
99 $draftForms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? $draft['forms'] : null;
100 foreach ( (array) call_user_func( $this->cf7Forms ) as $form ) {
101 $id = (int) $form['id'];
102 $fields = array_values( array_map( 'strval', (array) call_user_func( $this->cf7Fields, $id ) ) );
103 $detected = self::detectEmailField( $fields );
104 $enabled = ! empty( $form['enabled'] );
105
106 if ( $draftForms !== null ) {
107 $selected = array_key_exists( (string) $id, $draftForms ) || array_key_exists( $id, $draftForms );
108 $field = $selected ? (string) ( $draftForms[ $id ] ?? $draftForms[ (string) $id ] ?? '' ) : $detected;
109 } else {
110 $selected = ! $enabled && $detected !== '';
111 $field = $detected;
112 }
113
114 $forms[] = array(
115 'id' => $id,
116 'title' => (string) $form['title'],
117 'enabled' => $enabled,
118 'fields' => $fields,
119 'detectedField' => $detected,
120 'field' => $field,
121 'selected' => ! $enabled && $selected,
122 );
123 }
124
125 $plugins = array();
126 foreach ( $this->detector->withoutAddon() as $plugin ) {
127 $plugin['productUrl'] = self::productUrl( 'wizard-detected-' . $plugin['id'] );
128 $plugins[] = $plugin;
129 }
130
131 $pageId = (int) ( $draft['pageId'] ?? 0 );
132
133 return array(
134 'status' => $state['status'],
135 'step' => $state['step'],
136 'steps' => SetupState::STEPS,
137 'sender' => array(
138 'email' => (string) ( $draft['sender'] ?? ( $defaults['sender'] !== '' ? $defaults['sender'] : get_bloginfo( 'admin_email' ) ) ),
139 'name' => (string) ( $draft['sender_name'] ?? ( $defaults['sender_name'] !== '' ? $defaults['sender_name'] : wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ) ),
140 'domain' => self::siteDomain(),
141 ),
142 'cf7' => array(
143 'installed' => defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7_ContactForm' ),
144 'newFormUrl' => admin_url( 'admin.php?page=wpcf7-new' ),
145 'installUrl' => admin_url( 'plugin-install.php?s=contact+form+7&tab=search&type=term' ),
146 'forms' => $forms,
147 ),
148 'mail' => array(
149 'subject' => (string) ( $draft['subject'] ?? $this->composer->subject() ),
150 'design' => SetupMailComposer::isDesign( (string) ( $draft['design'] ?? '' ) ) ? (string) $draft['design'] : SetupMailComposer::DEFAULT_DESIGN,
151 'designs' => SetupMailComposer::DESIGNS,
152 ),
153 'page' => array(
154 'mode' => $pageId > 0 && empty( $draft['pageCreated'] ) ? 'existing' : 'new',
155 'pageId' => $this->isPublishedPage( $pageId ) ? $pageId : 0,
156 'pages' => array_values( (array) call_user_func( $this->pages ) ),
157 ),
158 'testMailTo' => self::currentUserEmail(),
159 'formPlugins' => $plugins,
160 'editorUrl' => self::productUrl( 'wizard-editor' ),
161 );
162 }
163
164 /**
165 * Save one step.
166 *
167 * @param array<string, mixed> $data
168 *
169 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
170 */
171 public function saveStep( string $step, array $data ): array {
172 switch ( $step ) {
173 case self::STEP_SENDER:
174 return $this->saveSender( $data );
175 case self::STEP_FORMS:
176 return $this->saveForms( $data );
177 case self::STEP_MAIL:
178 return $this->saveMail( $data );
179 case self::STEP_PAGE:
180 return $this->savePage( $data );
181 }
182 return self::fail( 'step', __( 'Unknown setup step.', 'double-opt-in' ) );
183 }
184
185 /**
186 * @param array<string, mixed> $data
187 */
188 private function saveSender( array $data ): array {
189 $email = sanitize_email( (string) ( $data['email'] ?? '' ) );
190 $name = sanitize_text_field( (string) ( $data['name'] ?? '' ) );
191
192 if ( ! is_email( $email ) ) {
193 return self::fail( 'email', __( 'Please enter a valid email address.', 'double-opt-in' ) );
194 }
195
196 FormDefaults::save( $email, $name );
197 $this->state->saveStep(
198 self::STEPS[ self::STEP_SENDER ],
199 array(
200 'sender' => $email,
201 'sender_name' => $name,
202 )
203 );
204 return self::ok();
205 }
206
207 /**
208 * @param array<string, mixed> $data `forms`: list of {id, field}.
209 */
210 private function saveForms( array $data ): array {
211 $known = array();
212 foreach ( (array) call_user_func( $this->cf7Forms ) as $form ) {
213 if ( empty( $form['enabled'] ) ) {
214 $known[ (int) $form['id'] ] = true;
215 }
216 }
217
218 $chosen = array();
219 foreach ( (array) ( $data['forms'] ?? array() ) as $entry ) {
220 if ( ! is_array( $entry ) ) {
221 continue;
222 }
223 $id = (int) ( $entry['id'] ?? 0 );
224 $field = sanitize_text_field( (string) ( $entry['field'] ?? '' ) );
225
226 if ( ! isset( $known[ $id ] ) ) {
227 continue;
228 }
229 $fields = array_map( 'strval', (array) call_user_func( $this->cf7Fields, $id ) );
230 if ( ! in_array( $field, $fields, true ) ) {
231 return self::fail(
232 'forms',
233 __( 'Please choose the email field for every selected form.', 'double-opt-in' )
234 );
235 }
236 $chosen[ $id ] = $field;
237 }
238
239 $this->state->saveStep( self::STEPS[ self::STEP_FORMS ], array( 'forms' => $chosen ) );
240 return self::ok();
241 }
242
243 /**
244 * @param array<string, mixed> $data
245 */
246 private function saveMail( array $data ): array {
247 $subject = sanitize_text_field( (string) ( $data['subject'] ?? '' ) );
248 $design = (string) ( $data['design'] ?? '' );
249
250 if ( trim( $subject ) === '' ) {
251 return self::fail( 'subject', __( 'Please enter a subject.', 'double-opt-in' ) );
252 }
253 if ( ! SetupMailComposer::isDesign( $design ) ) {
254 $design = SetupMailComposer::DEFAULT_DESIGN;
255 }
256
257 $this->state->saveStep(
258 self::STEPS[ self::STEP_MAIL ],
259 array(
260 'subject' => $subject,
261 'design' => $design,
262 )
263 );
264 return self::ok();
265 }
266
267 /**
268 * @param array<string, mixed> $data `mode`: new|existing, `pageId` for existing.
269 */
270 private function savePage( array $data ): array {
271 $mode = (string) ( $data['mode'] ?? 'new' );
272
273 if ( $mode === 'existing' ) {
274 $pageId = (int) ( $data['pageId'] ?? 0 );
275 if ( ! $this->isPublishedPage( $pageId ) ) {
276 return self::fail( 'pageId', __( 'Please choose a published page.', 'double-opt-in' ) );
277 }
278 $this->state->saveStep(
279 self::STEPS[ self::STEP_PAGE ],
280 array(
281 'pageId' => $pageId,
282 'pageCreated' => false,
283 )
284 );
285 return self::ok( array( 'pageId' => $pageId ) );
286 }
287
288 $pageId = $this->ensureConfirmationPage();
289 if ( $pageId <= 0 ) {
290 return self::fail( 'page', __( 'The page could not be created. Please choose an existing page instead.', 'double-opt-in' ) );
291 }
292 $this->state->saveStep(
293 self::STEPS[ self::STEP_PAGE ],
294 array(
295 'pageId' => $pageId,
296 'pageCreated' => true,
297 )
298 );
299 return self::ok( array( 'pageId' => $pageId ) );
300 }
301
302 /**
303 * Create the confirmation page once, published. Reuses the page from an
304 * earlier run while it is still published.
305 */
306 private function ensureConfirmationPage(): int {
307 $draft = $this->state->draft();
308 $existing = (int) ( $draft['pageId'] ?? 0 );
309 if ( ! empty( $draft['pageCreated'] ) && $this->isPublishedPage( $existing ) ) {
310 return $existing;
311 }
312
313 if ( ! current_user_can( 'publish_pages' ) ) {
314 return 0;
315 }
316
317 // The shortcode tells a confirmed link from an expired or invalid one
318 // (5.8.0); a fixed "confirmed" text said so for every click.
319 $content = '<!-- wp:shortcode -->[doi_confirmation_status]<!-- /wp:shortcode -->';
320
321 $id = wp_insert_post(
322 array(
323 'post_type' => 'page',
324 'post_status' => 'publish',
325 'post_title' => __( 'Email address confirmed', 'double-opt-in' ),
326 'post_content' => $content,
327 'comment_status' => 'closed',
328 ),
329 true
330 );
331
332 return is_int( $id ) ? $id : 0;
333 }
334
335 /**
336 * Send the configured mail to the current user, with sample values.
337 *
338 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
339 */
340 public function sendTestMail(): array {
341 $to = self::currentUserEmail();
342 if ( ! is_email( $to ) ) {
343 return self::fail( 'testMail', __( 'Your user account has no valid email address.', 'double-opt-in' ) );
344 }
345
346 $key = 'f12_doi_setup_test_mail_' . get_current_user_id();
347 $count = (int) get_transient( $key );
348 if ( $count >= self::TEST_MAIL_LIMIT ) {
349 return self::fail( 'testMail', __( 'You have sent several test emails in a short time. Please try again in an hour.', 'double-opt-in' ) );
350 }
351 set_transient( $key, $count + 1, self::TEST_MAIL_WINDOW );
352
353 $overview = $this->overview();
354 $draft = $this->state->draft();
355 $pageId = (int) ( $draft['pageId'] ?? 0 );
356 // Points at the confirmation page without an opt-in hash: the admin
357 // sees where visitors land, and nothing gets confirmed.
358 $link = $pageId > 0 ? (string) get_permalink( $pageId ) : home_url( '/' );
359
360 $body = strtr(
361 $this->composer->body( (string) $overview['mail']['design'] ),
362 array(
363 '[doubleoptinlink]' => esc_url( $link ),
364 '[doubleoptin_form_url]' => esc_url( home_url( '/' ) ),
365 '[doubleoptin_form_date]' => (string) wp_date( (string) get_option( 'date_format', 'Y-m-d' ) ),
366 '[doubleoptin_form_time]' => (string) wp_date( (string) get_option( 'time_format', 'H:i' ) ),
367 '[doubleoptin_form_email]' => $to,
368 )
369 );
370
371 $headers = array( 'Content-Type: text/html; charset=UTF-8' );
372 $sender = (string) $overview['sender']['email'];
373 $name = (string) $overview['sender']['name'];
374 if ( is_email( $sender ) ) {
375 $headers[] = 'From: ' . ( $name !== '' ? self::headerName( $name ) . ' <' . $sender . '>' : $sender );
376 }
377
378 $sent = wp_mail(
379 $to,
380 sprintf(
381 /* translators: %s: subject of the confirmation email */
382 __( '[Test] %s', 'double-opt-in' ),
383 (string) $overview['mail']['subject']
384 ),
385 $body,
386 $headers
387 );
388
389 if ( $sent === false ) {
390 return self::fail( 'testMail', __( 'WordPress could not send the email. Please check the mail settings of your site, for example with an SMTP plugin.', 'double-opt-in' ) );
391 }
392
393 return self::ok( array( 'to' => $to ) );
394 }
395
396 /**
397 * Turn the collected values into form settings and switch the forms on.
398 *
399 * @return array{ok: bool, errors: array<string, string>, data?: array<string, mixed>}
400 */
401 public function finish(): array {
402 $draft = $this->state->draft();
403 $forms = isset( $draft['forms'] ) && is_array( $draft['forms'] ) ? $draft['forms'] : array();
404 $design = (string) ( $draft['design'] ?? SetupMailComposer::DEFAULT_DESIGN );
405 $subject = (string) ( $draft['subject'] ?? $this->composer->subject() );
406 $pageId = (int) ( $draft['pageId'] ?? 0 );
407 $sender = FormDefaults::get();
408 $body = $this->composer->body( $design );
409
410 $stillFree = array();
411 foreach ( (array) call_user_func( $this->cf7Forms ) as $form ) {
412 if ( empty( $form['enabled'] ) ) {
413 $stillFree[ (int) $form['id'] ] = true;
414 }
415 }
416
417 $enabled = array();
418 $incomplete = array();
419 foreach ( $forms as $formId => $field ) {
420 $formId = (int) $formId;
421 if ( ! isset( $stillFree[ $formId ] ) ) {
422 continue;
423 }
424
425 $dto = $this->settings->getSettings( $formId );
426 $dto->recipient = '[' . (string) $field . ']';
427 $dto->subject = $subject;
428 $dto->body = $body;
429 $dto->template = '';
430 if ( $sender['sender'] !== '' ) {
431 $dto->sender = $sender['sender'];
432 }
433 if ( $sender['sender_name'] !== '' ) {
434 $dto->senderName = $sender['sender_name'];
435 }
436 if ( $this->isPublishedPage( $pageId ) ) {
437 $dto->confirmationPage = $pageId;
438 }
439
440 $missing = $dto->getMissingRequiredFields();
441 if ( $missing !== array() ) {
442 $incomplete[] = array(
443 'id' => $formId,
444 'missing' => $missing,
445 );
446 continue;
447 }
448
449 $dto->enabled = true;
450 $this->settings->saveSettings( $formId, $dto );
451 $enabled[] = $formId;
452 }
453
454 $this->state->complete();
455
456 return self::ok(
457 array(
458 'enabled' => $enabled,
459 'incomplete' => $incomplete,
460 )
461 );
462 }
463
464 /**
465 * The field that most likely holds the visitor's address.
466 *
467 * @param string[] $fields
468 */
469 public static function detectEmailField( array $fields ): string {
470 if ( $fields === array() ) {
471 return '';
472 }
473 $mapping = PlaceholderMapper::autoDetectMapping( $fields );
474 return isset( $mapping['doi_email'] ) ? (string) $mapping['doi_email'] : '';
475 }
476
477 private function isPublishedPage( int $pageId ): bool {
478 if ( $pageId <= 0 ) {
479 return false;
480 }
481 foreach ( (array) call_user_func( $this->pages ) as $page ) {
482 if ( (int) $page['id'] === $pageId ) {
483 return true;
484 }
485 }
486 return false;
487 }
488
489 /**
490 * @return array<int, array{id: int, title: string, enabled: bool}>
491 */
492 public static function listCf7Forms(): array {
493 $integration = self::cf7Integration();
494 if ( $integration === null ) {
495 return array();
496 }
497 $forms = array();
498 foreach ( $integration->getForms() as $form ) {
499 $forms[] = array(
500 'id' => (int) $form['id'],
501 'title' => (string) $form['title'],
502 'enabled' => ! empty( $form['enabled'] ),
503 );
504 }
505 return $forms;
506 }
507
508 /**
509 * @return string[]
510 */
511 public static function listCf7Fields( int $formId ): array {
512 $integration = self::cf7Integration();
513 if ( $integration === null ) {
514 return array();
515 }
516 return array_values( array_map( 'strval', array_keys( $integration->getFormFields( $formId ) ) ) );
517 }
518
519 /**
520 * @return array<int, array{id: int, title: string}>
521 */
522 public static function listPublishedPages(): array {
523 $pages = array();
524 foreach ( get_pages( array( 'post_status' => 'publish' ) ) as $page ) {
525 $pages[] = array(
526 'id' => (int) $page->ID,
527 'title' => (string) $page->post_title,
528 );
529 }
530 return $pages;
531 }
532
533 private static function cf7Integration(): ?\Forge12\DoubleOptIn\Integration\FormIntegrationInterface {
534 if ( ! class_exists( FormIntegrationRegistry::class ) ) {
535 return null;
536 }
537 $integration = FormIntegrationRegistry::getInstance()->get( 'cf7' );
538 return ( $integration !== null && $integration->isAvailable() ) ? $integration : null;
539 }
540
541 private static function currentUserEmail(): string {
542 $user = get_userdata( get_current_user_id() );
543 return ( $user && isset( $user->user_email ) ) ? (string) $user->user_email : '';
544 }
545
546 private static function siteDomain(): string {
547 $host = (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST );
548 return strpos( $host, 'www.' ) === 0 ? substr( $host, 4 ) : $host;
549 }
550
551 private static function productUrl( string $from ): string {
552 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_product_url';
553 return function_exists( $fn ) ? (string) $fn( $from ) : '';
554 }
555
556 /**
557 * A display name safe for a mail header.
558 */
559 private static function headerName( string $name ): string {
560 $name = str_replace( array( "\r", "\n", '"' ), '', $name );
561 return '"' . $name . '"';
562 }
563
564 /**
565 * @param array<string, mixed> $data
566 */
567 private static function ok( array $data = array() ): array {
568 return array(
569 'ok' => true,
570 'errors' => array(),
571 'data' => $data,
572 );
573 }
574
575 private static function fail( string $field, string $message ): array {
576 return array(
577 'ok' => false,
578 'errors' => array( $field => $message ),
579 );
580 }
581 }
582