PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
5.8.0 5.8.1 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 All 41 releases
double-opt-in / src / Admin / SingleConsentExportController.php

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

161 lines 5.5 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 // Core exports a single record. Bulk and filtered exports are the
62 // Consent Export add-on's own endpoints, not a locked mode of this one.
63 if ( $scope !== 'single' ) {
64 wp_die( esc_html__( 'This endpoint exports a single record. Bulk exports are provided by the Consent Export add-on.', 'double-opt-in' ) );
65 }
66
67 $id = isset( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0;
68 if ( $id <= 0 ) {
69 wp_die( __( 'No records found.', 'double-opt-in' ) );
70 }
71
72 $optIn = $this->repository->findById( $id );
73 if ( ! $optIn ) {
74 wp_die( __( 'No records found.', 'double-opt-in' ) );
75 }
76
77 $record = $this->formatRecord( $optIn );
78 $format = isset( $_REQUEST['format'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['format'] ) ) : 'csv';
79 $format = in_array( $format, array( 'csv', 'json' ), true ) ? $format : 'csv';
80
81 $filename = sanitize_file_name( 'consent-export-' . gmdate( 'Y-m-d-His' ) );
82
83 if ( $format === 'json' ) {
84 $content = wp_json_encode( array( $record ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
85 header( 'Content-Type: application/json; charset=utf-8' );
86 header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '.json"' );
87 } else {
88 $content = $this->toCsv( $record );
89 header( 'Content-Type: text/csv; charset=utf-8' );
90 header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '.csv"' );
91 }
92
93 header( 'Content-Length: ' . strlen( $content ) );
94 header( 'Cache-Control: no-cache, no-store, must-revalidate' );
95 header( 'Pragma: no-cache' );
96 header( 'Expires: 0' );
97
98 echo $content;
99 exit;
100 }
101
102 private function formatRecord( OptIn $optIn ): array {
103 $dateFormat = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
104
105 return array(
106 'id' => $optIn->getId(),
107 'email' => $optIn->getEmail(),
108 'form_id' => $optIn->getFormId(),
109 'confirmed' => $optIn->isConfirmed() ? 'Yes' : 'No',
110 'opted_out' => $optIn->isOptedOut() ? 'Yes' : 'No',
111 'consent_text' => $optIn->getConsentText(),
112 'registration_date' => $optIn->getCreateTime() > 0
113 ? wp_date( $dateFormat, $optIn->getCreateTime() )
114 : '',
115 'confirmation_date' => $optIn->getUpdateTime() > 0 && $optIn->isConfirmed()
116 ? wp_date( $dateFormat, $optIn->getUpdateTime() )
117 : '',
118 'optout_date' => $optIn->getOptOutTime() > 0
119 ? wp_date( $dateFormat, $optIn->getOptOutTime() )
120 : '',
121 'registration_ip' => $optIn->getIpRegister(),
122 'confirmation_ip' => $optIn->getIpConfirmation(),
123 'optout_ip' => $optIn->getIpOptOut(),
124 'hash' => $optIn->getHash(),
125 );
126 }
127
128 private function toCsv( array $record ): string {
129 $output = fopen( 'php://temp', 'r+' );
130 fwrite( $output, "\xEF\xBB\xBF" );
131 fputcsv( $output, array_map( array( self::class, 'neutraliseCsvCell' ), array_keys( $record ) ) );
132 fputcsv( $output, array_map( array( self::class, 'neutraliseCsvCell' ), array_values( $record ) ) );
133 rewind( $output );
134 $csv = stream_get_contents( $output );
135 fclose( $output );
136
137 return $csv;
138 }
139
140 /**
141 * Neutralise CSV/formula injection (OWASP): a spreadsheet evaluates any
142 * cell whose value starts with = + - @ (or a leading tab/CR that can
143 * smuggle one, or the full-width variants =+-@) as a formula. Values
144 * here include attacker-influenced fields (e.g. a spoofed X-Forwarded-For
145 * IP, or the email). Prefixing with a single quote stops evaluation while
146 * keeping the value human-readable.
147 *
148 * Pure + static so it is unit-testable without WordPress.
149 *
150 * @param mixed $value
151 * @return string
152 */
153 public static function neutraliseCsvCell( $value ): string {
154 $value = (string) $value;
155 if ( $value !== '' && preg_match( '/^[=+\-@\t\r\x{FF1D}\x{FF0B}\x{FF0D}\x{FF20}]/u', $value ) ) {
156 return "'" . $value;
157 }
158 return $value;
159 }
160 }
161