<?php declare(strict_types = 1);
namespace MailPoet\Router\Endpoints;
if (!defined('ABSPATH')) exit;
use MailPoet\Config\AccessControl;
use MailPoet\Config\Env;
use MailPoet\Newsletter\Statistics\Export\StatisticsExporter;
use MailPoet\Router\Router;
use MailPoet\Subscribers\ImportExport\Export\Export;
use MailPoet\Util\Helpers;
use MailPoet\Util\Security;
use MailPoet\WP\Functions as WPFunctions;
class ExportDownload {
public const ENDPOINT = 'export_download';
public const ACTION_SUBSCRIBER_EXPORT = 'subscriberExport';
public const ACTION_STATISTICS_EXPORT = 'statisticsExport';
private const DOWNLOAD_TOKEN_LENGTH = 32;
private const DOWNLOAD_TOKEN_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz0123456789';
private const EXPORT_DIRECTORY = 'exports';
private const CONTENT_TYPES = [
'csv' => 'text/csv; charset=utf-8',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
/** @var string[] */
public $allowedActions = [
self::ACTION_SUBSCRIBER_EXPORT,
self::ACTION_STATISTICS_EXPORT,
];
public $permissions = [
'actions' => [
self::ACTION_SUBSCRIBER_EXPORT => AccessControl::PERMISSION_MANAGE_SUBSCRIBERS,
self::ACTION_STATISTICS_EXPORT => AccessControl::PERMISSION_MANAGE_EMAILS,
],
];
/** @var WPFunctions */
private $wp;
public function __construct(
WPFunctions $wp
) {
$this->wp = $wp;
}
public static function buildSubscriberExportUrl(string $token, string $format, ?string $baseUrl = null): string {
return self::buildExportUrl(self::ACTION_SUBSCRIBER_EXPORT, $token, $format, $baseUrl);
}
public static function buildStatisticsExportUrl(string $token, string $format, ?string $baseUrl = null): string {
return self::buildExportUrl(self::ACTION_STATISTICS_EXPORT, $token, $format, $baseUrl);
}
public static function getExportDirectory(): string {
return Env::$tempPath . '/' . self::EXPORT_DIRECTORY;
}
/**
* @return array{path: string, token: string}
*/
public static function createExportFile(string $filePrefix, string $format): array {
$token = self::generateDownloadToken();
return [
'path' => self::getFilePathForToken($filePrefix, $token, $format),
'token' => $token,
];
}
public static function generateDownloadToken(): string {
return Security::generateRandomString(self::DOWNLOAD_TOKEN_LENGTH);
}
public static function getFilePathForToken(string $filePrefix, string $token, string $format): string {
return self::getExportDirectory() . '/' . self::getFileNameForToken($filePrefix, $token, $format);
}
public static function ensureExportDirectory(?WPFunctions $wp = null): void {
$wp = $wp ?? WPFunctions::get();
$exportDirectory = self::getExportDirectory();
$wasJustCreated = !is_dir($exportDirectory);
if ($wasJustCreated && !$wp->wpMkdirP($exportDirectory)) {
throw new \RuntimeException('Could not create the export directory.');
}
if (!is_dir($exportDirectory)) {
throw new \RuntimeException('Could not create the export directory.');
}
if (!self::writeFile($exportDirectory . '/index.php', str_replace('\n', PHP_EOL, '<?php\n\n// Silence is golden'))) {
throw new \RuntimeException('Could not protect the export directory.');
}
$htaccessWritten = self::writeFile(
$exportDirectory . '/.htaccess',
implode(PHP_EOL, [
'<IfModule mod_authz_core.c>',
'Require all denied',
'</IfModule>',
'<IfModule !mod_authz_core.c>',
'Deny from all',
'</IfModule>',
'',
])
);
if (!$htaccessWritten) {
throw new \RuntimeException('Could not protect the export directory.');
}
if ($wasJustCreated) {
self::purgeLegacyExportFiles();
}
}
private static function purgeLegacyExportFiles(): void {
$patterns = [
Env::$tempPath . '/' . Export::getFilePrefix() . '*.*',
Env::$tempPath . '/' . StatisticsExporter::FILE_PREFIX . '*.*',
];
foreach ($patterns as $pattern) {
foreach (glob($pattern) ?: [] as $file) {
if (is_file($file)) {
unlink($file);
}
}
}
}
public function subscriberExport(array $data): void {
$this->downloadFile($data, Export::getFilePrefix());
}
public function statisticsExport(array $data): void {
$this->downloadFile($data, StatisticsExporter::FILE_PREFIX);
}
public function getDownloadFilePath(array $data, string $filePrefix): ?string {
if (empty($data['token']) || !is_string($data['token'])) {
return null;
}
$token = $data['token'];
if (
strlen($token) !== self::DOWNLOAD_TOKEN_LENGTH
|| strspn($token, self::DOWNLOAD_TOKEN_CHARACTERS) !== self::DOWNLOAD_TOKEN_LENGTH
) {
return null;
}
if (empty($data['format']) || !is_string($data['format'])) {
return null;
}
$extension = strtolower($data['format']);
if (!isset(self::CONTENT_TYPES[$extension])) {
return null;
}
$realExportPath = realpath(self::getExportDirectory());
$filePath = self::getFilePathForToken($filePrefix, $token, $extension);
$realFilePath = is_file($filePath) ? realpath($filePath) : false;
if (!is_string($realExportPath) || !is_string($realFilePath)) {
return null;
}
$exportPath = rtrim($realExportPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($realFilePath, $exportPath) !== 0) {
return null;
}
return $realFilePath;
}
private static function buildExportUrl(string $action, string $token, string $format, ?string $baseUrl): string {
$baseUrl = $baseUrl ?? WPFunctions::get()->homeUrl();
$params = [
Router::NAME => '',
'endpoint' => self::ENDPOINT,
'action' => Helpers::camelCaseToUnderscore($action),
'data' => Router::encodeRequestData([
'token' => $token,
'format' => strtolower($format),
]),
];
$separator = strpos($baseUrl, '?') === false ? '?' : '&';
return $baseUrl . $separator . http_build_query($params, '', '&');
}
private static function getFileNameForToken(string $filePrefix, string $token, string $format): string {
return sprintf(
'%s%s.%s',
$filePrefix,
hash_hmac('sha256', $token, self::getDownloadSecret()),
strtolower($format)
);
}
private static function getDownloadSecret(): string {
$secret = '';
$secretConstants = [
'AUTH_KEY',
'SECURE_AUTH_KEY',
'LOGGED_IN_KEY',
'NONCE_KEY',
'AUTH_SALT',
'SECURE_AUTH_SALT',
'LOGGED_IN_SALT',
'NONCE_SALT',
];
foreach ($secretConstants as $constant) {
if (defined($constant)) {
$secret .= (string)constant($constant);
}
}
return $secret !== '' ? $secret : (string)Env::$path;
}
private static function writeFile(string $filePath, string $contents): bool {
// phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Reads protection files in MailPoet's export directory.
if (is_file($filePath) && file_get_contents($filePath) === $contents) {
return true;
}
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents -- Writes protection files to MailPoet's export directory.
return file_put_contents($filePath, $contents) !== false;
}
private function downloadFile(array $data, string $filePrefix): void {
$filePath = $this->getDownloadFilePath($data, $filePrefix);
if (!$filePath) {
$this->wp->statusHeader(404);
exit;
}
$extension = strtolower((string)pathinfo($filePath, PATHINFO_EXTENSION));
if (!$this->wp->headersSent()) {
header('Content-Type: ' . self::CONTENT_TYPES[$extension]);
header('Content-Disposition: attachment; filename="' . rtrim($filePrefix, '_') . '.' . $extension . '"');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
$fileSize = filesize($filePath);
if ($fileSize !== false) {
header('Content-Length: ' . $fileSize);
}
}
// phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Reads a validated local export file from Env::$tempPath.
readfile($filePath);
exit;
}
}