# double-opt-in/5.6.0/src/Admin/SingleConsentExportController.php

Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification, version 5.6.0. 160 lines.

- Page: https://pluginprobe.com/plugins/double-opt-in/5.6.0/code/src/Admin/SingleConsentExportController.php
- Raw: https://pluginprobe.com/plugins/double-opt-in/5.6.0/raw/src/Admin/SingleConsentExportController.php
- Modified: 2026-07-13T12:16:42+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/double-opt-in/5.6.0/code/src/Admin/SingleConsentExportController.php#L10-L20`.

```php
<?php
/**
 * Single Consent Export Controller
 *
 * Handles the single-record consent export (JSON/CSV) from the opt-in detail view.
 * The bulk export (all records, by email) is a Pro-only feature.
 *
 * @package Forge12\DoubleOptIn\Admin
 * @since   3.6.0
 */

namespace Forge12\DoubleOptIn\Admin;

use Forge12\DoubleOptIn\Entity\OptIn;
use Forge12\DoubleOptIn\Repository\OptInRepositoryInterface;
use Forge12\Shared\LoggerInterface;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class SingleConsentExportController {

	private LoggerInterface $logger;
	private OptInRepositoryInterface $repository;

	public function __construct( LoggerInterface $logger, OptInRepositoryInterface $repository ) {
		$this->logger     = $logger;
		$this->repository = $repository;
	}

	/**
	 * Register the AJAX action.
	 *
	 * Only registers if the Pro plugin has not already registered a handler.
	 *
	 * @return void
	 */
	public function registerActions(): void {
		// Register at default priority (10). The Pro plugin registers at priority 5
		// and removes this handler, providing extended export features.
		add_action( 'wp_ajax_doi_export_consent', array( $this, 'handleExport' ) );
	}

	/**
	 * Handle the single-record export request.
	 *
	 * @return void
	 */
	public function handleExport(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( __( 'You do not have permission to perform this action.', 'double-opt-in' ), 403 );
		}

		if ( ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce( wp_unslash( $_REQUEST['_wpnonce'] ), 'doi_consent_export' ) ) {
			wp_die( __( 'Security check failed.', 'double-opt-in' ), 403 );
		}

		$scope = isset( $_REQUEST['scope'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['scope'] ) ) : '';

		// Free version only supports single-record export
		if ( $scope !== 'single' ) {
			wp_die( __( 'Bulk export requires the Pro version.', 'double-opt-in' ) );
		}

		$id = isset( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0;
		if ( $id <= 0 ) {
			wp_die( __( 'No records found.', 'double-opt-in' ) );
		}

		$optIn = $this->repository->findById( $id );
		if ( ! $optIn ) {
			wp_die( __( 'No records found.', 'double-opt-in' ) );
		}

		$record = $this->formatRecord( $optIn );
		$format = isset( $_REQUEST['format'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['format'] ) ) : 'csv';
		$format = in_array( $format, array( 'csv', 'json' ), true ) ? $format : 'csv';

		$filename = sanitize_file_name( 'consent-export-' . gmdate( 'Y-m-d-His' ) );

		if ( $format === 'json' ) {
			$content = wp_json_encode( array( $record ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );
			header( 'Content-Type: application/json; charset=utf-8' );
			header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '.json"' );
		} else {
			$content = $this->toCsv( $record );
			header( 'Content-Type: text/csv; charset=utf-8' );
			header( 'Content-Disposition: attachment; filename="' . esc_attr( $filename ) . '.csv"' );
		}

		header( 'Content-Length: ' . strlen( $content ) );
		header( 'Cache-Control: no-cache, no-store, must-revalidate' );
		header( 'Pragma: no-cache' );
		header( 'Expires: 0' );

		echo $content;
		exit;
	}

	private function formatRecord( OptIn $optIn ): array {
		$dateFormat = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );

		return array(
			'id'                => $optIn->getId(),
			'email'             => $optIn->getEmail(),
			'form_id'           => $optIn->getFormId(),
			'confirmed'         => $optIn->isConfirmed() ? 'Yes' : 'No',
			'opted_out'         => $optIn->isOptedOut() ? 'Yes' : 'No',
			'consent_text'      => $optIn->getConsentText(),
			'registration_date' => $optIn->getCreateTime() > 0
				? wp_date( $dateFormat, $optIn->getCreateTime() )
				: '',
			'confirmation_date' => $optIn->getUpdateTime() > 0 && $optIn->isConfirmed()
				? wp_date( $dateFormat, $optIn->getUpdateTime() )
				: '',
			'optout_date'       => $optIn->getOptOutTime() > 0
				? wp_date( $dateFormat, $optIn->getOptOutTime() )
				: '',
			'registration_ip'   => $optIn->getIpRegister(),
			'confirmation_ip'   => $optIn->getIpConfirmation(),
			'optout_ip'         => $optIn->getIpOptOut(),
			'hash'              => $optIn->getHash(),
		);
	}

	private function toCsv( array $record ): string {
		$output = fopen( 'php://temp', 'r+' );
		fwrite( $output, "\xEF\xBB\xBF" );
		fputcsv( $output, array_map( array( self::class, 'neutraliseCsvCell' ), array_keys( $record ) ) );
		fputcsv( $output, array_map( array( self::class, 'neutraliseCsvCell' ), array_values( $record ) ) );
		rewind( $output );
		$csv = stream_get_contents( $output );
		fclose( $output );

		return $csv;
	}

	/**
	 * Neutralise CSV/formula injection (OWASP): a spreadsheet evaluates any
	 * cell whose value starts with = + - @ (or a leading tab/CR that can
	 * smuggle one, or the full-width variants ＝＋－＠) as a formula. Values
	 * here include attacker-influenced fields (e.g. a spoofed X-Forwarded-For
	 * IP, or the email). Prefixing with a single quote stops evaluation while
	 * keeping the value human-readable.
	 *
	 * Pure + static so it is unit-testable without WordPress.
	 *
	 * @param mixed $value
	 * @return string
	 */
	public static function neutraliseCsvCell( $value ): string {
		$value = (string) $value;
		if ( $value !== '' && preg_match( '/^[=+\-@\t\r\x{FF1D}\x{FF0B}\x{FF0D}\x{FF20}]/u', $value ) ) {
			return "'" . $value;
		}
		return $value;
	}
}

```
