| 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 |
|