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 / Repository / OptInRepository.php

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

575 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OptIn Repository Implementation
4 *
5 * @package Forge12\DoubleOptIn\Repository
6 * @since 4.0.0
7 */
8
9 namespace Forge12\DoubleOptIn\Repository;
10
11 use Forge12\DoubleOptIn\Entity\OptIn;
12 use Forge12\Shared\LoggerInterface;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Class OptInRepository
20 *
21 * Database operations for OptIn entities using WordPress wpdb.
22 */
23 class OptInRepository implements OptInRepositoryInterface {
24
25 private \wpdb $wpdb;
26 private string $table;
27 private LoggerInterface $logger;
28
29 /**
30 * Constructor.
31 *
32 * @param \wpdb $wpdb The WordPress database instance.
33 * @param LoggerInterface $logger The logger.
34 */
35 public function __construct( \wpdb $wpdb, LoggerInterface $logger ) {
36 $this->wpdb = $wpdb;
37 $this->table = $wpdb->prefix . 'f12_cf7_doubleoptin';
38 $this->logger = $logger;
39 }
40
41 /**
42 * {@inheritdoc}
43 */
44 public function findById( int $id ): ?OptIn {
45 $row = $this->wpdb->get_row(
46 $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id ),
47 ARRAY_A
48 );
49
50 if ( ! $row ) {
51 $this->logger->debug(
52 'OptIn not found by ID',
53 array(
54 'plugin' => 'double-opt-in',
55 'id' => $id,
56 )
57 );
58 return null;
59 }
60
61 return OptIn::fromArray( $row );
62 }
63
64 /**
65 * {@inheritdoc}
66 */
67 public function findByHash( string $hash ): ?OptIn {
68 $hash = sanitize_text_field( $hash );
69
70 $row = $this->wpdb->get_row(
71 $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE hash = %s", $hash ),
72 ARRAY_A
73 );
74
75 if ( ! $row ) {
76 $this->logger->debug(
77 'OptIn not found by hash',
78 array(
79 'plugin' => 'double-opt-in',
80 'hash' => $hash,
81 )
82 );
83 return null;
84 }
85
86 return OptIn::fromArray( $row );
87 }
88
89 /**
90 * {@inheritdoc}
91 */
92 public function findByEmail( string $email ): array {
93 $email = sanitize_email( $email );
94
95 $rows = $this->wpdb->get_results(
96 $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE email = %s ORDER BY id DESC", $email ),
97 ARRAY_A
98 );
99
100 return array_map( array( OptIn::class, 'fromArray' ), $rows ?: array() );
101 }
102
103 /**
104 * {@inheritdoc}
105 */
106 public function findConfirmedByEmail( string $email ): array {
107 $email = sanitize_email( $email );
108
109 $rows = $this->wpdb->get_results(
110 $this->wpdb->prepare(
111 "SELECT * FROM {$this->table} WHERE email = %s AND doubleoptin = 1 ORDER BY id DESC",
112 $email
113 ),
114 ARRAY_A
115 );
116
117 return array_map( array( OptIn::class, 'fromArray' ), $rows ?: array() );
118 }
119
120 /**
121 * {@inheritdoc}
122 */
123 public function findUnconfirmedByEmail( string $email ): array {
124 $email = sanitize_email( $email );
125
126 $rows = $this->wpdb->get_results(
127 $this->wpdb->prepare(
128 "SELECT * FROM {$this->table} WHERE email = %s AND doubleoptin = 0 ORDER BY id DESC",
129 $email
130 ),
131 ARRAY_A
132 );
133
134 return array_map( array( OptIn::class, 'fromArray' ), $rows ?: array() );
135 }
136
137 /**
138 * {@inheritdoc}
139 */
140 public function findByCategory( int $categoryId, array $options = array() ): array {
141 $perPage = max( 1, (int) ( $options['perPage'] ?? 10 ) );
142 $page = max( 1, (int) ( $options['page'] ?? 1 ) );
143 $orderBy = in_array( $options['orderBy'] ?? 'id', array( 'id', 'createtime', 'updatetime', 'email' ), true )
144 ? $options['orderBy']
145 : 'id';
146 $order = strtoupper( $options['order'] ?? 'DESC' ) === 'ASC' ? 'ASC' : 'DESC';
147 $keyword = $options['keyword'] ?? '';
148 $offset = ( $page - 1 ) * $perPage;
149
150 $where = 'WHERE category = %d';
151 $params = array( $categoryId );
152
153 if ( ! empty( $keyword ) ) {
154 $where .= ' AND content LIKE %s';
155 $params[] = '%%' . $this->wpdb->esc_like( $keyword ) . '%%';
156 }
157
158 $query = $this->wpdb->prepare(
159 "SELECT * FROM {$this->table} {$where} ORDER BY {$orderBy} {$order} LIMIT %d OFFSET %d",
160 array_merge( $params, array( $perPage, $offset ) )
161 );
162
163 $rows = $this->wpdb->get_results( $query, ARRAY_A );
164
165 $this->logger->debug(
166 'OptIns fetched by category',
167 array(
168 'plugin' => 'double-opt-in',
169 'category' => $categoryId,
170 'count' => count( $rows ?: array() ),
171 )
172 );
173
174 return array_map( array( OptIn::class, 'fromArray' ), $rows ?: array() );
175 }
176
177 /**
178 * {@inheritdoc}
179 */
180 public function countByCategory( int $categoryId, string $keyword = '' ): int {
181 $where = 'WHERE category = %d';
182 $params = array( $categoryId );
183
184 if ( ! empty( $keyword ) ) {
185 $where .= ' AND content LIKE %s';
186 $params[] = '%%' . $this->wpdb->esc_like( $keyword ) . '%%';
187 }
188
189 $query = $this->wpdb->prepare(
190 "SELECT COUNT(*) FROM {$this->table} {$where}",
191 $params
192 );
193
194 return (int) $this->wpdb->get_var( $query );
195 }
196
197 /**
198 * {@inheritdoc}
199 */
200 public function countByFormId( int $formId ): int {
201 return (int) $this->wpdb->get_var(
202 $this->wpdb->prepare(
203 "SELECT COUNT(*) FROM {$this->table} WHERE cf_form_id = %d",
204 $formId
205 )
206 );
207 }
208
209 /**
210 * {@inheritdoc}
211 */
212 public function save( OptIn $optIn ): OptIn {
213 if ( $optIn->isNew() ) {
214 return $this->insert( $optIn );
215 }
216
217 return $this->update( $optIn );
218 }
219
220 /**
221 * Insert a new OptIn.
222 *
223 * @param OptIn $optIn The OptIn entity.
224 *
225 * @return OptIn The entity with ID and hash.
226 */
227 private function insert( OptIn $optIn ): OptIn {
228 $data = $optIn->toInsertArray();
229
230 $result = $this->wpdb->insert( $this->table, $data );
231
232 if ( $result === false ) {
233 $this->logger->error(
234 'Failed to insert OptIn',
235 array(
236 'plugin' => 'double-opt-in',
237 'error' => $this->wpdb->last_error,
238 )
239 );
240 throw new \RuntimeException( 'Failed to save OptIn: ' . $this->wpdb->last_error );
241 }
242
243 $id = $this->wpdb->insert_id;
244 $optIn->setId( $id );
245
246 // Generate cryptographically secure hash
247 $hash = bin2hex( random_bytes( 32 ) );
248 $this->wpdb->update( $this->table, array( 'hash' => $hash ), array( 'id' => $id ) );
249 $optIn->setHash( $hash );
250
251 $this->logger->info(
252 'OptIn created',
253 array(
254 'plugin' => 'double-opt-in',
255 'id' => $id,
256 'hash' => $hash,
257 )
258 );
259
260 return $optIn;
261 }
262
263 /**
264 * Update an existing OptIn.
265 *
266 * @param OptIn $optIn The OptIn entity.
267 *
268 * @return OptIn
269 */
270 private function update( OptIn $optIn ): OptIn {
271 $data = $optIn->toArray();
272 $id = $data['id'];
273 unset( $data['id'] );
274
275 $result = $this->wpdb->update( $this->table, $data, array( 'id' => $id ) );
276
277 if ( $result === false ) {
278 $this->logger->error(
279 'Failed to update OptIn',
280 array(
281 'plugin' => 'double-opt-in',
282 'id' => $id,
283 'error' => $this->wpdb->last_error,
284 )
285 );
286 throw new \RuntimeException( 'Failed to update OptIn: ' . $this->wpdb->last_error );
287 }
288
289 $this->logger->info(
290 'OptIn updated',
291 array(
292 'plugin' => 'double-opt-in',
293 'id' => $id,
294 )
295 );
296
297 return $optIn;
298 }
299
300 /**
301 * {@inheritdoc}
302 */
303 public function delete( OptIn $optIn ): bool {
304 $result = $this->wpdb->delete( $this->table, array( 'id' => $optIn->getId() ) );
305
306 if ( $result === false ) {
307 $this->logger->error(
308 'Failed to delete OptIn',
309 array(
310 'plugin' => 'double-opt-in',
311 'id' => $optIn->getId(),
312 'error' => $this->wpdb->last_error,
313 )
314 );
315 return false;
316 }
317
318 $this->logger->info(
319 'OptIn deleted',
320 array(
321 'plugin' => 'double-opt-in',
322 'id' => $optIn->getId(),
323 )
324 );
325
326 return true;
327 }
328
329 /**
330 * {@inheritdoc}
331 */
332 public function deleteByHash( string $hash ): int {
333 $hash = sanitize_text_field( $hash );
334
335 $result = $this->wpdb->delete( $this->table, array( 'hash' => $hash ) );
336
337 if ( $result === false ) {
338 $this->logger->error(
339 'Failed to delete OptIn by hash',
340 array(
341 'plugin' => 'double-opt-in',
342 'hash' => $hash,
343 'error' => $this->wpdb->last_error,
344 )
345 );
346 return 0;
347 }
348
349 $this->logger->info(
350 'OptIn deleted by hash',
351 array(
352 'plugin' => 'double-opt-in',
353 'hash' => $hash,
354 'deleted' => $result,
355 )
356 );
357
358 return (int) $result;
359 }
360
361 /**
362 * {@inheritdoc}
363 */
364 public function bulkUpdateCategory( int $fromCategoryId, int $toCategoryId ): int {
365 $result = $this->wpdb->update(
366 $this->table,
367 array( 'category' => $toCategoryId ),
368 array( 'category' => $fromCategoryId )
369 );
370
371 $this->logger->info(
372 'Bulk category update',
373 array(
374 'plugin' => 'double-opt-in',
375 'from' => $fromCategoryId,
376 'to' => $toCategoryId,
377 'updated' => $result,
378 )
379 );
380
381 return $result === false ? 0 : (int) $result;
382 }
383
384 /**
385 * {@inheritdoc}
386 */
387 public function deleteOlderThan( int $timestamp, bool $confirmed ): int {
388 $dateTime = gmdate( 'Y-m-d H:i:s', $timestamp );
389 $status = $confirmed ? 1 : 0;
390
391 $result = $this->wpdb->query(
392 $this->wpdb->prepare(
393 "DELETE FROM {$this->table} WHERE createtime < %s AND doubleoptin = %d",
394 $dateTime,
395 $status
396 )
397 );
398
399 $this->logger->notice(
400 'OptIns older than threshold deleted',
401 array(
402 'plugin' => 'double-opt-in',
403 'threshold' => $dateTime,
404 'confirmed' => $confirmed,
405 'deleted' => $result,
406 )
407 );
408
409 return $result === false ? 0 : (int) $result;
410 }
411
412 /**
413 * {@inheritdoc}
414 */
415 public function findEligibleForReminder( int $delaySeconds, int $safetyFloorSeconds = 0, int $limit = 50 ): array {
416 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $delaySeconds );
417 $floor = $safetyFloorSeconds > 0
418 ? gmdate( 'Y-m-d H:i:s', time() - $safetyFloorSeconds )
419 : '1970-01-01 00:00:00';
420
421 $sql = $this->wpdb->prepare(
422 "SELECT * FROM {$this->table}
423 WHERE doubleoptin = 0
424 AND (reminder_sent_at IS NULL OR reminder_sent_at = '')
425 AND email IS NOT NULL AND email != ''
426 AND (optouttime IS NULL OR optouttime = '' OR optouttime = '0')
427 AND createtime < %s
428 AND createtime > %s
429 ORDER BY createtime ASC
430 LIMIT %d",
431 $cutoff,
432 $floor,
433 $limit
434 );
435
436 $rows = $this->wpdb->get_results( $sql, ARRAY_A );
437
438 $this->logger->debug(
439 'OptIns eligible for reminder fetched',
440 array(
441 'plugin' => 'double-opt-in',
442 'count' => count( $rows ?: array() ),
443 'cutoff' => $cutoff,
444 'floor' => $floor,
445 )
446 );
447
448 return array_map( array( OptIn::class, 'fromArray' ), $rows ?: array() );
449 }
450
451 /**
452 * Find all OptIns with pagination and filtering.
453 *
454 * @param array $options Query options (perPage, page, order, keyword, cf_form_id).
455 * @param int|null $numberOfPages Reference to store total page count.
456 *
457 * @return array<OptIn>
458 */
459 public function findAll( array $options = array(), ?int &$numberOfPages = null ): array {
460 $perPage = max( 1, (int) ( $options['perPage'] ?? 10 ) );
461 $page = max( 1, (int) ( $options['page'] ?? 1 ) );
462 $order = strtoupper( $options['order'] ?? 'DESC' ) === 'ASC' ? 'ASC' : 'DESC';
463 $keyword = $options['keyword'] ?? '';
464 $formId = $options['cf_form_id'] ?? '';
465 $offset = ( $page - 1 ) * $perPage;
466
467 $where = array();
468 $params = array();
469
470 if ( ! empty( $keyword ) ) {
471 $where[] = 'content LIKE %s';
472 $params[] = '%%' . $this->wpdb->esc_like( $keyword ) . '%%';
473 }
474
475 if ( ! empty( $formId ) ) {
476 $where[] = 'cf_form_id = %d';
477 $params[] = (int) $formId;
478 }
479
480 $whereClause = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';
481
482 // Count total for pagination
483 if ( $numberOfPages !== null ) {
484 $countQuery = "SELECT COUNT(*) FROM {$this->table} {$whereClause}";
485 if ( ! empty( $params ) ) {
486 $countQuery = $this->wpdb->prepare( $countQuery, $params );
487 }
488 $totalItems = (int) $this->wpdb->get_var( $countQuery );
489 $numberOfPages = $totalItems > 0 ? (int) ceil( $totalItems / $perPage ) : 0;
490
491 if ( $numberOfPages === 0 ) {
492 return array();
493 }
494 }
495
496 // Main query
497 $query = "SELECT * FROM {$this->table} {$whereClause} ORDER BY id {$order} LIMIT %d OFFSET %d";
498 $params[] = $perPage;
499 $params[] = $offset;
500
501 $rows = $this->wpdb->get_results(
502 $this->wpdb->prepare( $query, $params ),
503 ARRAY_A
504 );
505
506 $this->logger->debug(
507 'OptIns fetched',
508 array(
509 'plugin' => 'double-opt-in',
510 'count' => count( $rows ?: array() ),
511 'page' => $page,
512 )
513 );
514
515 return array_map( array( OptIn::class, 'fromArray' ), $rows ?: array() );
516 }
517
518 /**
519 * Update category for a single OptIn by ID.
520 *
521 * @param int $optInId The OptIn ID.
522 * @param int $categoryId The new category ID.
523 *
524 * @return bool
525 */
526 public function updateCategoryById( int $optInId, int $categoryId ): bool {
527 $result = $this->wpdb->update(
528 $this->table,
529 array( 'category' => $categoryId ),
530 array( 'id' => $optInId )
531 );
532
533 $this->logger->info(
534 'OptIn category updated',
535 array(
536 'plugin' => 'double-opt-in',
537 'optInId' => $optInId,
538 'category' => $categoryId,
539 'result' => $result,
540 )
541 );
542
543 return $result !== false;
544 }
545
546 /**
547 * {@inheritdoc}
548 */
549 public function existsByEmailAndFormId( string $email, int $formId, bool $confirmedOnly = true ): bool {
550 $sql = "SELECT COUNT(*) FROM {$this->table} WHERE email = %s AND cf_form_id = %d";
551
552 if ( $confirmedOnly ) {
553 $sql .= ' AND doubleoptin = 1';
554 }
555
556 // Opt-Outs release the email (non-opted-out entries only)
557 $sql .= " AND (optouttime IS NULL OR optouttime = '' OR optouttime = '0')";
558
559 $count = (int) $this->wpdb->get_var(
560 $this->wpdb->prepare( $sql, sanitize_email( $email ), $formId )
561 );
562
563 return $count > 0;
564 }
565
566 /**
567 * Get the logger instance.
568 *
569 * @return LoggerInterface
570 */
571 public function getLogger(): LoggerInterface {
572 return $this->logger;
573 }
574 }
575