PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.1.5
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.1.5
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 / core / CleanUp.class.php

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

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