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 / SingleConsentExportController.php

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

160 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Single Consent Export Controller
4 *
5 * Handles the single-record consent export (JSON/CSV) from the opt-in detail view.
6 * The bulk export (all records, by email) is a Pro-only feature.
7 *
8 * @package Forge12\DoubleOptIn\Admin
9 * @since 3.6.0
10 */
11
12 namespace Forge12\DoubleOptIn\Admin;
13
14 use Forge12\DoubleOptIn\Entity\OptIn;
15 use Forge12\DoubleOptIn\Repository\OptInRepositoryInterface;
16 use Forge12\Shared\LoggerInterface;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 class SingleConsentExportController {
23
24 private LoggerInterface $logger;
25 private OptInRepositoryInterface $repository;
26
27 public function __construct( LoggerInterface $logger, OptInRepositoryInterface $repository ) {
28 $this->logger = $logger;
29 $this->repository = $repository;
30 }
31
32 /**
33 * Register the AJAX action.
34 *
35 * Only registers if the Pro plugin has not already registered a handler.
36 *
37 * @return void
38 */
39 public function registerActions(): void {
40 // Register at default priority (10). The Pro plugin registers at priority 5
41 // and removes this handler, providing extended export features.
42 add_action( 'wp_ajax_doi_export_consent', array( $this, 'handleExport' ) );
43 }
44
45 /**
46 * Handle the single-record export request.
47 *
48 * @return void
49 */
50 public function handleExport(): void {
51 if ( ! current_user_can( 'manage_options' ) ) {
52 wp_die( __( 'You do not have permission to perform this action.', 'double-opt-in' ), 403 );
53 }
54
55 if ( ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( wp_unslash( $_REQUEST['_wpnonce'] ), 'doi_consent_export' ) ) {
56 wp_die( __( 'Security check failed.', 'double-opt-in' ), 403 );
57 }
58
59 $scope = isset( $_REQUEST['scope'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['scope'] ) ) : '';
60
61 // Free version only supports single-record export
62 if ( $scope !== 'single' ) {
63 wp_die( __( 'Bulk export requires the Pro version.', 'double-opt-in' ) );
64 }
65
66 $id = isset( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0;
67 if ( $id <= 0 ) {
68 wp_die( __( 'No records found.', 'double-opt-in' ) );
69 }
70
71 $optIn = $this->repository->findById( $id );
72 if ( ! $optIn ) {
73 wp_die( __( 'No records found.', 'double-opt-in' ) );
74 }
75
76 $record = $this->formatRecord( $optIn );
77 $format = isset( $_REQUEST['format'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['format'] ) ) : 'csv';
78 $format = in_array( $format, array( 'csv', 'json' ), true ) ? $format : 'csv';
79
80 $filename = sanitize_file_name( 'consent-export-' . gmdate( 'Y-m-d-His' ) );
81
82 if ( $format === 'json' ) {
83 $content = wp_json_encode( array( $record ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
84 header( 'Content-Type: application/json; charset=utf-8' );
85 header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '.json"' );
86 } else {
87 $content = $this->toCsv( $record );
88 header( 'Content-Type: text/csv; charset=utf-8' );
89 header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '.csv"' );
90 }
91
92 header( 'Content-Length: ' . strlen( $content ) );
93 header( 'Cache-Control: no-cache, no-store, must-revalidate' );
94 header( 'Pragma: no-cache' );
95 header( 'Expires: 0' );
96
97 echo $content;
98 exit;
99 }
100
101 private function formatRecord( OptIn $optIn ): array {
102 $dateFormat = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
103
104 return array(
105 'id' => $optIn->getId(),
106 'email' => $optIn->getEmail(),
107 'form_id' => $optIn->getFormId(),
108 'confirmed' => $optIn->isConfirmed() ? 'Yes' : 'No',
109 'opted_out' => $optIn->isOptedOut() ? 'Yes' : 'No',
110 'consent_text' => $optIn->getConsentText(),
111 'registration_date' => $optIn->getCreateTime() > 0
112 ? wp_date( $dateFormat, $optIn->getCreateTime() )
113 : '',
114 'confirmation_date' => $optIn->getUpdateTime() > 0 && $optIn->isConfirmed()
115 ? wp_date( $dateFormat, $optIn->getUpdateTime() )
116 : '',
117 'optout_date' => $optIn->getOptOutTime() > 0
118 ? wp_date( $dateFormat, $optIn->getOptOutTime() )
119 : '',
120 'registration_ip' => $optIn->getIpRegister(),
121 'confirmation_ip' => $optIn->getIpConfirmation(),
122 'optout_ip' => $optIn->getIpOptOut(),
123 'hash' => $optIn->getHash(),
124 );
125 }
126
127 private function toCsv( array $record ): string {
128 $output = fopen( 'php://temp', 'r+' );
129 fwrite( $output, "\xEF\xBB\xBF" );
130 fputcsv( $output, array_map( array( self::class, 'neutraliseCsvCell' ), array_keys( $record ) ) );
131 fputcsv( $output, array_map( array( self::class, 'neutraliseCsvCell' ), array_values( $record ) ) );
132 rewind( $output );
133 $csv = stream_get_contents( $output );
134 fclose( $output );
135
136 return $csv;
137 }
138
139 /**
140 * Neutralise CSV/formula injection (OWASP): a spreadsheet evaluates any
141 * cell whose value starts with = + - @ (or a leading tab/CR that can
142 * smuggle one, or the full-width variants =+-@) as a formula. Values
143 * here include attacker-influenced fields (e.g. a spoofed X-Forwarded-For
144 * IP, or the email). Prefixing with a single quote stops evaluation while
145 * keeping the value human-readable.
146 *
147 * Pure + static so it is unit-testable without WordPress.
148 *
149 * @param mixed $value
150 * @return string
151 */
152 public static function neutraliseCsvCell( $value ): string {
153 $value = (string) $value;
154 if ( $value !== '' && preg_match( '/^[=+\-@\t\r\x{FF1D}\x{FF0B}\x{FF0D}\x{FF20}]/u', $value ) ) {
155 return "'" . $value;
156 }
157 return $value;
158 }
159 }
160