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 / core / CleanUp.class.php

CleanUp.class.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.5.0, at core/CleanUp.class.php

638 lines 21.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace forge12\contactform7\CF7DoubleOptIn {
4
5 use Forge12\DoubleOptIn\Container\Container;
6 use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface;
7 use Forge12\DoubleOptIn\Events\Lifecycle\OptInDeletedEvent;
8 use Forge12\DoubleOptIn\Events\Lifecycle\OptInExpiredEvent;
9 use Forge12\Shared\LoggerInterface;
10
11 if(!defined('ABSPATH')){
12 exit;
13 }
14 /**
15 * This class will handle the clean up of the database
16 * as defined by the user settings.
17 */
18 class CleanUp
19 {
20 private LoggerInterface $logger;
21
22 public function __construct(LoggerInterface $logger)
23 {
24 $this->logger = $logger;
25
26 $this->logger->info(
27 'Daily opt-in cleaner initialized.',
28 [
29 'plugin' => 'double-opt-in',
30 'class' => static::class,
31 ]
32 );
33
34 $this->registerHooks();
35 }
36
37 /**
38 * Register all WordPress hooks for the opt-in cleanup.
39 */
40 private function registerHooks(): void
41 {
42 add_action('dailyOptinClear', [$this, 'handleDailyOptinCleanup']);
43
44 $this->logger->debug(
45 'Daily opt-in cleanup hook registered.',
46 [
47 'plugin' => 'double-opt-in',
48 'hook' => 'dailyOptinClear',
49 'handler'=> 'handleDailyOptinCleanup',
50 ]
51 );
52 }
53
54 /**
55 * Central dispatcher for daily opt-in cleanup.
56 * Keeps cron infrastructure separated from business logic.
57 */
58 public function handleDailyOptinCleanup(): void
59 {
60 $this->logger->info(
61 'Executing daily opt-in cleanup.',
62 [
63 'plugin' => 'double-opt-in',
64 ]
65 );
66
67 $this->removeUnconfirmedOptins();
68 $this->removeConfirmedOptins();
69
70 $this->logger->info(
71 'Daily opt-in cleanup finished.',
72 [
73 'plugin' => 'double-opt-in',
74 ]
75 );
76 }
77
78 public function get_logger(): LoggerInterface{
79 return $this->logger;
80 }
81
82 /**
83 * Delete the given hash entry
84 *
85 * No native return type: `int|false` is a PHP 8.0 union type and the
86 * plugin supports PHP 7.4.
87 *
88 * @param string $hash The opt-in hash to delete.
89 *
90 * @return int|false Number of deleted rows, or false on failure.
91 */
92 public function deleteByHash(string $hash){
93 // Authorization check (fail fast)
94 if (!current_user_can('manage_options')) {
95 $this->get_logger()->warning('Unauthorized attempt to delete opt-in entry.', [
96 'plugin' => 'double-opt-in',
97 'method' => __METHOD__,
98 'user_id' => get_current_user_id(),
99 'required_capability' => 'manage_options',
100 ]);
101
102 return false;
103 }
104
105 // Input validation
106 $hash = trim($hash);
107 if ($hash === '') {
108 $this->get_logger()->error('Deletion aborted due to invalid hash value.', [
109 'plugin' => 'double-opt-in',
110 'method' => __METHOD__,
111 ]);
112
113 return false;
114 }
115
116 global $wpdb;
117
118 // Log the start of the deletion attempt.
119 $this->get_logger()->info( 'Attempting to delete opt-in entry by hash.', [
120 'plugin' => 'double-opt-in',
121 'hash' => $hash,
122 ] );
123
124 $table_name = $wpdb->prefix . 'f12_cf7_doubleoptin';
125
126 // Pre-fetch the row before the DELETE so the pre-delete cascade
127 // hook can fire with the full payload. Listeners need
128 // content/files/cf_form_id to clean up form-system data
129 // (pre-doi-data-retention Step 1, 2026-05-08).
130 $row = $wpdb->get_row(
131 $wpdb->prepare( "SELECT id, hash, content, files, cf_form_id FROM {$table_name} WHERE hash = %s", $hash ),
132 ARRAY_A
133 );
134
135 $this->get_logger()->info('Deleting opt-in entry by hash.', [
136 'plugin' => 'double-opt-in',
137 'method' => __METHOD__,
138 'hash' => $hash,
139 ]);
140
141 if ( is_array( $row ) ) {
142 /** @see f12_doi_optin_pre_delete in CleanUp::removeOlderThan for contract. */
143 do_action( 'f12_doi_optin_pre_delete', $row );
144 }
145
146 $sql = $wpdb->prepare(
147 "DELETE FROM {$table_name} WHERE hash = %s",
148 $hash
149 );
150
151 $result = $wpdb->query($sql);
152
153 if ($result === false) {
154 $this->get_logger()->error('Database error while deleting opt-in entry.', [
155 'plugin' => 'double-opt-in',
156 'method' => __METHOD__,
157 'hash' => $hash,
158 'error' => $wpdb->last_error,
159 ]);
160
161 return false;
162 }
163
164 if ($result === 0) {
165 $this->get_logger()->notice('No opt-in entry found for provided hash.', [
166 'plugin' => 'double-opt-in',
167 'method' => __METHOD__,
168 'hash' => $hash,
169 ]);
170
171 return 0;
172 }
173
174 $this->get_logger()->notice('Opt-in entry successfully deleted.', [
175 'plugin' => 'double-opt-in',
176 'method' => __METHOD__,
177 'hash' => $hash,
178 'rows_deleted' => $result,
179 ]);
180
181 // Dispatch typed event for new event-driven architecture
182 $this->dispatchOptInDeletedEvent( $hash, 'manual', get_current_user_id() );
183
184 return $result;
185 }
186
187 /**
188 * Delete all Rows from the Database which are older than the given timestamp and where the optin value matches as defined.
189 * @param $timestamp
190 * @param int $optin
191 */
192 protected function removeOlderThan(int $timestamp, int $optin = 0): void
193 {
194 // Defensive validation: timestamp must be plausible
195 if ($timestamp <= 0) {
196 $this->get_logger()->warning(
197 'Aborted opt-in cleanup due to invalid timestamp.',
198 [
199 'plugin' => 'double-opt-in',
200 'timestamp' => $timestamp,
201 ]
202 );
203 return;
204 }
205
206 // Normalize opt-in status (allow only 0 or 1)
207 $optin = ($optin === 1) ? 1 : 0;
208
209 $statusLabel = ($optin === 1) ? 'confirmed' : 'unconfirmed';
210
211 $this->get_logger()->info(
212 'Starting cleanup of opt-in entries older than timestamp.',
213 [
214 'plugin' => 'double-opt-in',
215 'timestamp' => $timestamp,
216 'status' => $statusLabel,
217 ]
218 );
219
220 global $wpdb;
221 $tableName = $wpdb->prefix . 'f12_cf7_doubleoptin';
222 $dateTime = gmdate('Y-m-d H:i:s', $timestamp);
223
224 // Pre-fetch the rows that are about to be deleted so we can
225 // dispatch per-row events around the DELETE. Listeners need
226 // the hash for post-delete coordination (file-storage cascade
227 // from file-lifecycle Schritt 0) AND the full row payload
228 // for pre-delete cascade-cleanup of form-system data
229 // (pre-doi-data-retention Step 1, 2026-05-08): the integration
230 // listener reads content/files/cf_form_id to find what to
231 // delete in the form plugin's own storage (WPForms entries,
232 // GF entries, Avada/Elementor file URLs).
233 //
234 // ARRAY_A so $rowsToDelete elements are associative arrays —
235 // simpler for listeners than juggling stdClass.
236 $selectSql = $wpdb->prepare(
237 "SELECT id, hash, content, files, cf_form_id FROM {$tableName} WHERE createtime < %s AND doubleoptin = %d",
238 $dateTime,
239 $optin
240 );
241 $rowsToDelete = (array) $wpdb->get_results( $selectSql, ARRAY_A );
242
243 // Pre-delete cascade hook fires BEFORE the DELETE so listeners
244 // can read the row's content/files/cf_form_id and clean up
245 // their integration-specific side-effects in form-system
246 // storage. The post-delete event below is too late — the row
247 // is gone, listeners can't read its payload.
248 foreach ( $rowsToDelete as $row ) {
249 /**
250 * Fires per row BEFORE an opt-in is deleted (cron path).
251 *
252 * Listeners cascade-delete form-system data (entries +
253 * uploaded files in form-plugin storage). See
254 * plan/pre-doi-data-retention.md.
255 *
256 * @since 4.3.0
257 *
258 * @param array $row Associative row: id, hash, content,
259 * files, cf_form_id.
260 */
261 do_action( 'f12_doi_optin_pre_delete', $row );
262 }
263
264 // Prepare SQL securely (OWASP-compliant)
265 $sql = $wpdb->prepare("DELETE FROM {$tableName} WHERE createtime < %s AND doubleoptin = %d",
266 $dateTime,
267 $optin
268 );
269
270 $this->get_logger()->debug(
271 'Executing opt-in cleanup query.',
272 [
273 'plugin' => 'double-opt-in',
274 'sql' => $sql,
275 'datetime' => $dateTime,
276 'matched' => count( $rowsToDelete ),
277 ]
278 );
279
280 $result = $wpdb->query($sql);
281
282 if ($result === false) {
283 $this->get_logger()->error(
284 'Database error while removing old opt-in entries.',
285 [
286 'plugin' => 'double-opt-in',
287 'wpdb_error' => $wpdb->last_error,
288 'status' => $statusLabel,
289 ]
290 );
291 return;
292 }
293
294 $this->get_logger()->notice(
295 'Opt-in cleanup completed successfully.',
296 [
297 'plugin' => 'double-opt-in',
298 'rows_deleted' => (int) $result,
299 'status' => $statusLabel,
300 'older_than' => $dateTime,
301 ]
302 );
303
304 // Dispatch per-row OptInDeletedEvent for every actually-deleted
305 // hash. Reason carries cron context so listeners can distinguish
306 // expired vs manual deletion if needed (e.g. for audit logs).
307 if ( (int) $result > 0 ) {
308 $reason = 'cron_expired_' . $statusLabel;
309 foreach ( $rowsToDelete as $row ) {
310 $hash = is_object( $row ) ? ( $row->hash ?? '' ) : ( $row['hash'] ?? '' );
311 if ( $hash !== '' ) {
312 $this->dispatchOptInDeletedEvent( (string) $hash, $reason, null );
313 }
314 }
315
316 // Keep the legacy aggregate event for back-compat —
317 // existing listeners (audit dashboard, telemetry) expect it.
318 $this->dispatchOptInExpiredEvent( $statusLabel, (int) $result, $timestamp );
319 }
320 }
321
322 /**
323 * Delete all DOI entries
324 */
325 public function reset(): bool{
326 // Authorization check (fail fast)
327 if (!current_user_can('manage_options')) {
328 $this->get_logger()->warning('Unauthorized attempt to reset double-opt-in table.', [
329 'plugin' => 'double-opt-in',
330 'method' => __METHOD__,
331 'user_id' => get_current_user_id(),
332 ]);
333
334 return false;
335 }
336 global $wpdb;
337
338 $table_name = $wpdb->prefix . 'f12_cf7_doubleoptin';
339
340 $this->get_logger()->critical('Confirmed full reset of double-opt-in table requested.', [
341 'plugin' => 'double-opt-in',
342 'method' => __METHOD__,
343 'table' => $table_name,
344 'note' => 'This operation will permanently delete ALL records.',
345 ]);
346
347 // TRUNCATE is faster and safer for full table resets
348 $sql = "TRUNCATE TABLE {$table_name}";
349
350 $result = $wpdb->query($sql);
351
352 if ($result === false) {
353 $this->get_logger()->error('Failed to reset double-opt-in table.', [
354 'plugin' => 'double-opt-in',
355 'method' => __METHOD__,
356 'table' => $table_name,
357 'error' => $wpdb->last_error,
358 ]);
359
360 return false;
361 }
362
363 $this->get_logger()->notice('Double-opt-in table has been fully reset.', [
364 'plugin' => 'double-opt-in',
365 'method' => __METHOD__,
366 'table' => $table_name,
367 ]);
368
369 return true;
370 }
371
372 /**
373 * Clear all unconfirmed database entries if the period selected
374 * is reached.
375 *
376 * Use force to delete confirmed opt-ins in the database settings.
377 */
378 public function removeUnconfirmedOptins(bool $force = false): void
379 {
380 $logger = $this->get_logger();
381
382 $logger->info('Starting unconfirmed opt-in cleanup process.', [
383 'plugin' => 'double-opt-in',
384 'force_mode' => $force,
385 ]);
386
387 $settings = CF7DoubleOptIn::getInstance()->getSettings();
388
389 $deleteAfter = isset($settings['delete_unconfirmed'])
390 ? (int) $settings['delete_unconfirmed']
391 : 0;
392 $period = isset($settings['delete_unconfirmed_period'])
393 ? (string) $settings['delete_unconfirmed_period']
394 : '';
395
396 $logger->debug('Loaded cleanup configuration.', [
397 'plugin' => 'double-opt-in',
398 'delete_unconfirmed' => $deleteAfter,
399 'period' => $period,
400 ]);
401
402 // Guard clause: feature disabled and not forced
403 if (!$force && $deleteAfter <= 0) {
404 $logger->notice('Unconfirmed opt-in cleanup is disabled. Aborting.', [
405 'plugin' => 'double-opt-in',
406 ]);
407 return;
408 }
409
410 // Force-mode short-circuit: an explicit user-button click
411 // ("Delete Unconfirmed Only" on the Database Management page)
412 // means "delete every unconfirmed opt-in NOW", regardless of
413 // the configured retention window. Reported by user 2026-04-30:
414 // "Delete Unconfirmed Only doesn't work" — the previous flow
415 // fell through to the configured period below, so an install
416 // with `delete_unconfirmed=30` + `period=days` would only
417 // delete unconfirmed entries older than 30 days, leaving every
418 // recent pending opt-in behind. force=true is the explicit
419 // override; cron-driven calls (force=false) continue to honour
420 // the configured retention period.
421 if ($force) {
422 $logger->info('Force mode enabled - deleting ALL unconfirmed opt-ins regardless of period.', [
423 'plugin' => 'double-opt-in',
424 ]);
425 // time()+86400 ensures every row's createtime < cutoff.
426 $timestamp = time() + 86400;
427 $this->removeOlderThan($timestamp, 0);
428 return;
429 }
430
431 // Log the configuration settings for unconfirmed opt-in removal.
432 $this->get_logger()->debug( 'Checking plugin settings for unconfirmed opt-in removal.', [
433 'plugin' => 'double-opt-in',
434 'delete_unconfirmed' => $settings['delete_unconfirmed'] ?? 'not set',
435 ] );
436
437 // Validate period
438 $allowedPeriods = ['days', 'weeks', 'months', 'years'];
439 if (!in_array($period, $allowedPeriods, true)) {
440 $logger->error('Invalid cleanup period configuration.', [
441 'plugin' => 'double-opt-in',
442 'period' => $period,
443 'allowed' => $allowedPeriods,
444 ]);
445 return;
446 }
447
448 $deleteAfter = max(0, (int)$deleteAfter);
449
450 $timestamp = strtotime(sprintf('-%d %s', $deleteAfter, $period));
451
452 if ($timestamp === false) {
453 $logger->error('Failed to calculate cleanup timestamp.', [
454 'plugin' => 'double-opt-in',
455 'delete_unconfirmed' => $deleteAfter,
456 'period' => $period,
457 ]);
458 return;
459 }
460
461 $logger->info('Removing unconfirmed opt-ins older than threshold.', [
462 'plugin' => 'double-opt-in',
463 'threshold_datetime' => date('Y-m-d H:i:s', $timestamp),
464 'threshold_timestamp' => $timestamp,
465 ]);
466
467 $this->removeOlderThan($timestamp, 0);
468
469 $logger->notice('Unconfirmed opt-in cleanup completed successfully.', [
470 'plugin' => 'double-opt-in',
471 ]);
472 }
473
474 /**
475 * Clear all confimred database entries if the period selected
476 * is reached.
477 *
478 * Use force to delete confirmed opt-ins in the database settings.
479 */
480 public function removeConfirmedOptins(bool $force = false): void
481 {
482 $this->get_logger()->info('Starting removal process for confirmed opt-ins.', [
483 'plugin' => 'double-opt-in',
484 'force_mode' => $force,
485 ]);
486 $settings = CF7DoubleOptIn::getInstance()->getSettings();
487
488 // Normalize and validate configuration values (settings are returned as strings)
489 $deleteAmount = isset($settings['delete']) ? (int) $settings['delete'] : 0;
490 $deletePeriod = isset($settings['delete_period']) ? trim((string) $settings['delete_period']) : '';
491
492 $this->get_logger()->debug('Loaded confirmed opt-in cleanup configuration.', [
493 'plugin' => 'double-opt-in',
494 'delete_amount' => $deleteAmount,
495 'delete_period' => $deletePeriod ?: 'not set',
496 ]);
497
498 // Guard clause: cleanup disabled and not forced
499 if ($deleteAmount <= 0 && !$force) {
500 $this->get_logger()->notice(
501 'Confirmed opt-in removal is disabled via configuration and not running in force mode. Aborting.',
502 ['plugin' => 'double-opt-in']
503 );
504 return;
505 }
506
507 // Force-mode short-circuit — same rationale as
508 // removeUnconfirmedOptins above. The "Delete Confirmed Only"
509 // admin button means "delete every confirmed opt-in NOW",
510 // not "respect the configured retention window".
511 if ($force) {
512 $this->get_logger()->info('Force mode enabled - deleting ALL confirmed opt-ins regardless of period.', [
513 'plugin' => 'double-opt-in',
514 ]);
515 $timestamp = time() + 86400;
516 $this->removeOlderThan($timestamp, 1);
517 return;
518 }
519
520 // Validate period to avoid invalid strtotime() behavior
521 $allowedPeriods = ['days', 'weeks', 'months', 'years'];
522 if (!in_array($deletePeriod, $allowedPeriods, true)) {
523 $this->get_logger()->error('Invalid delete period configured for confirmed opt-ins.', [
524 'plugin' => 'double-opt-in',
525 'period' => $deletePeriod,
526 ]);
527 return;
528 }
529
530 $this->get_logger()->info('Calculating cutoff timestamp for confirmed opt-in removal.', [
531 'plugin' => 'double-opt-in',
532 'age_threshold' => sprintf('%d %s', $deleteAmount, $deletePeriod),
533 ]);
534
535 $timestamp = strtotime(sprintf('-%d %s', $deleteAmount, $deletePeriod));
536
537 if ($timestamp === false) {
538 $this->get_logger()->error('Failed to calculate timestamp for confirmed opt-in cleanup.', [
539 'plugin' => 'double-opt-in',
540 'delete_amount' => $deleteAmount,
541 'delete_period' => $deletePeriod,
542 ]);
543 return;
544 }
545
546 $this->get_logger()->debug('Calculated cutoff timestamp for confirmed opt-ins.', [
547 'plugin' => 'double-opt-in',
548 'timestamp' => $timestamp,
549 'datetime' => date('Y-m-d H:i:s', $timestamp),
550 ]);
551
552 // Status "1" = confirmed opt-ins
553 $this->removeOlderThan($timestamp, 1);
554
555 $this->get_logger()->notice('Confirmed opt-in removal process completed successfully.', [
556 'plugin' => 'double-opt-in',
557 ]);
558 }
559
560 /**
561 * Dispatch OptInDeletedEvent via the new event system.
562 *
563 * @param string $hash The hash of the deleted opt-in.
564 * @param string $reason The reason for deletion.
565 * @param int|null $deletedBy The user ID who deleted (null for system).
566 *
567 * @since 4.0.0
568 */
569 /**
570 * Public so other deletion sites (REST handler, addon cleanup
571 * actors) can route through the same event-dispatch pipeline
572 * — single integration point for OptIn-deletion listeners,
573 * including the file-storage cascade-cleanup.
574 */
575 public function dispatchOptInDeletedEvent( string $hash, string $reason, ?int $deletedBy = null ): void {
576 try {
577 $container = Container::getInstance();
578 if ( $container->has( EventDispatcherInterface::class ) ) {
579 $dispatcher = $container->get( EventDispatcherInterface::class );
580 // OptInDeletedEvent's third arg is `string $deletedBy`
581 // — cron-context callers pass null (no logged-in
582 // user). Map null to 'system' so the typed
583 // constructor doesn't fatal. Also coerce int
584 // (manual REST: get_current_user_id()) to string
585 // explicitly.
586 $deletedByLabel = $deletedBy !== null ? (string) $deletedBy : 'system';
587 $event = new OptInDeletedEvent( $hash, $reason, $deletedByLabel );
588 $dispatcher->dispatch( $event );
589
590 $this->get_logger()->debug( 'OptInDeletedEvent dispatched', [
591 'plugin' => 'double-opt-in',
592 'hash' => $hash,
593 'reason' => $reason,
594 ] );
595 }
596 } catch ( \Exception $e ) {
597 $this->get_logger()->warning( 'Failed to dispatch OptInDeletedEvent', [
598 'plugin' => 'double-opt-in',
599 'error' => $e->getMessage(),
600 ] );
601 }
602 }
603
604 /**
605 * Dispatch OptInExpiredEvent via the new event system.
606 *
607 * @param string $cleanupType The type of cleanup (confirmed/unconfirmed).
608 * @param int $rowsDeleted Number of rows deleted.
609 * @param int $cutoffTime The cutoff timestamp.
610 *
611 * @since 4.0.0
612 */
613 private function dispatchOptInExpiredEvent( string $cleanupType, int $rowsDeleted, int $cutoffTime ): void {
614 try {
615 $container = Container::getInstance();
616 if ( $container->has( EventDispatcherInterface::class ) ) {
617 $dispatcher = $container->get( EventDispatcherInterface::class );
618 // Convert timestamp to DateTimeImmutable as expected by OptInExpiredEvent
619 $threshold = ( new \DateTimeImmutable() )->setTimestamp( $cutoffTime );
620 $event = new OptInExpiredEvent( $cleanupType, $rowsDeleted, $threshold );
621 $dispatcher->dispatch( $event );
622
623 $this->get_logger()->debug( 'OptInExpiredEvent dispatched', [
624 'plugin' => 'double-opt-in',
625 'cleanup_type' => $cleanupType,
626 'rows_deleted' => $rowsDeleted,
627 ] );
628 }
629 } catch ( \Exception $e ) {
630 $this->get_logger()->warning( 'Failed to dispatch OptInExpiredEvent', [
631 'plugin' => 'double-opt-in',
632 'error' => $e->getMessage(),
633 ] );
634 }
635 }
636
637 }
638 }