init
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class CaptchaConstants {
|
||||
const TYPE_BUILTIN = 'built-in';
|
||||
const TYPE_RECAPTCHA = 'recaptcha';
|
||||
const TYPE_RECAPTCHA_INVISIBLE = 'recaptcha-invisible';
|
||||
const TYPE_DISABLED = null;
|
||||
const TYPE_SETTING_NAME = 'captcha.type';
|
||||
const ON_REGISTER_FORMS_SETTING_NAME = 'captcha.on_register_forms.enabled';
|
||||
|
||||
public static function isReCaptcha(?string $captchaType) {
|
||||
return in_array($captchaType, [self::TYPE_RECAPTCHA, self::TYPE_RECAPTCHA_INVISIBLE]);
|
||||
}
|
||||
|
||||
public static function isBuiltIn(?string $captchaType) {
|
||||
return $captchaType === self::TYPE_BUILTIN;
|
||||
}
|
||||
|
||||
public static function isDisabled(?string $captchaType) {
|
||||
return $captchaType === self::TYPE_DISABLED || $captchaType === '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\Entities\FormEntity;
|
||||
use MailPoet\Form\FormsRepository;
|
||||
use MailPoet\Form\Renderer as FormRenderer;
|
||||
use MailPoet\Form\Util\Styles;
|
||||
use MailPoet\Util\Url as UrlHelper;
|
||||
|
||||
class CaptchaFormRenderer {
|
||||
/** @var UrlHelper */
|
||||
private $urlHelper;
|
||||
|
||||
/** @var CaptchaSession */
|
||||
private $captchaSession;
|
||||
|
||||
/** @var CaptchaPhrase */
|
||||
private $captchaPhrase;
|
||||
|
||||
/** @var CaptchaUrlFactory */
|
||||
private $captchaUrlFactory;
|
||||
|
||||
/** @var FormRenderer */
|
||||
private $formRenderer;
|
||||
|
||||
/** @var FormsRepository */
|
||||
private $formsRepository;
|
||||
|
||||
/** @var Styles */
|
||||
private $styles;
|
||||
|
||||
public function __construct(
|
||||
UrlHelper $urlHelper,
|
||||
CaptchaSession $captchaSession,
|
||||
CaptchaPhrase $captchaPhrase,
|
||||
CaptchaUrlFactory $urlFactory,
|
||||
FormsRepository $formsRepository,
|
||||
FormRenderer $formRenderer,
|
||||
Styles $styles
|
||||
) {
|
||||
$this->urlHelper = $urlHelper;
|
||||
$this->captchaSession = $captchaSession;
|
||||
$this->captchaPhrase = $captchaPhrase;
|
||||
$this->captchaUrlFactory = $urlFactory;
|
||||
$this->formRenderer = $formRenderer;
|
||||
$this->formsRepository = $formsRepository;
|
||||
$this->styles = $styles;
|
||||
}
|
||||
|
||||
public function render(array $data) {
|
||||
$sessionId = (isset($data['captcha_session_id']) && is_string($data['captcha_session_id']))
|
||||
? $data['captcha_session_id']
|
||||
: null;
|
||||
|
||||
if (!$sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($data['referrer_form'] == CaptchaUrlFactory::REFERER_MP_FORM) {
|
||||
return $this->renderFormInSubscriptionForm($sessionId);
|
||||
} elseif ($data['referrer_form'] == CaptchaUrlFactory::REFERER_WP_FORM) {
|
||||
return $this->renderFormInWPRegisterForm($data, 'wp-submit');
|
||||
} elseif ($data['referrer_form'] == CaptchaUrlFactory::REFERER_WC_FORM) {
|
||||
return $this->renderFormInWPRegisterForm($data, 'register');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function renderFormInSubscriptionForm($sessionId) {
|
||||
$captchaSessionForm = $this->captchaSession->getFormData($sessionId);
|
||||
$showSuccessMessage = !empty($_GET['mailpoet_success']);
|
||||
$showErrorMessage = !empty($_GET['mailpoet_error']);
|
||||
|
||||
$formId = 0;
|
||||
if (isset($captchaSessionForm['form_id'])) {
|
||||
$formId = (int)$captchaSessionForm['form_id'];
|
||||
} elseif ($showSuccessMessage) {
|
||||
$formId = (int)$_GET['mailpoet_success'];
|
||||
} elseif ($showErrorMessage) {
|
||||
$formId = (int)$_GET['mailpoet_error'];
|
||||
}
|
||||
|
||||
$formModel = $this->formsRepository->findOneById($formId);
|
||||
if (!$formModel instanceof FormEntity) {
|
||||
return false;
|
||||
} elseif ($showSuccessMessage) {
|
||||
// Display a success message in a no-JS flow
|
||||
return $this->renderFormMessages($formModel, true);
|
||||
}
|
||||
|
||||
$redirectUrl = htmlspecialchars($this->urlHelper->getCurrentUrl(), ENT_QUOTES);
|
||||
$hiddenFields = '<input type="hidden" name="data[form_id]" value="' . $formId . '" />';
|
||||
$hiddenFields .= '<input type="hidden" name="data[captcha_session_id]" value="' . htmlspecialchars($sessionId) . '" />';
|
||||
$hiddenFields .= '<input type="hidden" name="api_version" value="v1" />';
|
||||
$hiddenFields .= '<input type="hidden" name="endpoint" value="subscribers" />';
|
||||
$hiddenFields .= '<input type="hidden" name="mailpoet_method" value="subscribe" />';
|
||||
$hiddenFields .= '<input type="hidden" name="mailpoet_redirect" value="' . $redirectUrl . '" />';
|
||||
|
||||
$actionUrl = admin_url('admin-post.php?action=mailpoet_subscription_form');
|
||||
|
||||
$submitBlocks = $formModel->getBlocksByTypes(['submit']);
|
||||
$submitLabel = count($submitBlocks) && $submitBlocks[0]['params']['label']
|
||||
? $submitBlocks[0]['params']['label']
|
||||
: __('Subscribe', 'mailpoet');
|
||||
|
||||
$afterSubmitElement = $this->renderFormMessages($formModel, false, $showErrorMessage);
|
||||
|
||||
$styles = $this->styles->renderFormMessageStyles($formModel, '#mailpoet_captcha_form');
|
||||
$styles = '<style>' . $styles . '</style>';
|
||||
|
||||
return $this->renderForm($sessionId, $hiddenFields, $actionUrl, $submitLabel, $afterSubmitElement, $styles);
|
||||
}
|
||||
|
||||
private function renderFormInWPRegisterForm(array $data, string $submitLabelKey) {
|
||||
$sessionId = $data['captcha_session_id'];
|
||||
|
||||
unset($data['captcha_session_id']);
|
||||
// The 'name' attr is required in this format for the refresh button to work
|
||||
$hiddenFields = '<input type="hidden" name="data[captcha_session_id]" value="' . htmlspecialchars($sessionId) . '" />';
|
||||
|
||||
$actionUrl = $data['referrer_form_url'];
|
||||
unset($data['referrer_form_url']);
|
||||
|
||||
unset($data['referrer_form']);
|
||||
foreach ($data as $key => $value) {
|
||||
$hiddenFields .= '<input type="hidden" name="' . $key . '" value="' . htmlspecialchars($value) . '" />';
|
||||
}
|
||||
|
||||
$submitLabel = $data[$submitLabelKey] ?? esc_attr_e('Register'); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain
|
||||
|
||||
return $this->renderForm($sessionId, $hiddenFields, $actionUrl, $submitLabel);
|
||||
}
|
||||
|
||||
private function renderForm(
|
||||
$sessionId,
|
||||
$hiddenFields,
|
||||
$actionUrl,
|
||||
$submitLabel,
|
||||
$afterSubmitElement = null,
|
||||
$styles = null
|
||||
) {
|
||||
$this->captchaPhrase->createPhrase($sessionId);
|
||||
|
||||
$fields = [
|
||||
[
|
||||
'id' => 'captcha',
|
||||
'type' => 'text',
|
||||
'params' => [
|
||||
'label' => __('Type in the characters you see in the picture above:', 'mailpoet'),
|
||||
'value' => '',
|
||||
'obfuscate' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$form = array_merge(
|
||||
$fields,
|
||||
[
|
||||
[
|
||||
'id' => 'submit',
|
||||
'type' => 'submit',
|
||||
'params' => [
|
||||
'label' => $submitLabel,
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if ($afterSubmitElement) {
|
||||
// The 'mailpoet_form' class alter the form's submission behavior
|
||||
// Refer to mailpoet/assets/js/src/public.tsx
|
||||
$classes = 'mailpoet_form mailpoet_captcha_form';
|
||||
} else {
|
||||
$classes = 'mailpoet_captcha_form';
|
||||
}
|
||||
|
||||
$formHtml = '<form method="POST" action="' . $actionUrl . '" class="' . $classes . '" id="mailpoet_captcha_form" novalidate>';
|
||||
$formHtml .= $hiddenFields;
|
||||
|
||||
$width = 220;
|
||||
$height = 60;
|
||||
$captchaUrl = $this->captchaUrlFactory->getCaptchaImageUrl($width, $height, $sessionId);
|
||||
$mp3CaptchaUrl = $this->captchaUrlFactory->getCaptchaAudioUrl($sessionId);
|
||||
$reloadIcon = Env::$assetsUrl . '/img/icons/image-rotate.svg';
|
||||
$playIcon = Env::$assetsUrl . '/img/icons/controls-volumeon.svg';
|
||||
|
||||
$formHtml .= '<div class="mailpoet_form_hide_on_success">';
|
||||
$formHtml .= '<p class="mailpoet_paragraph">';
|
||||
$formHtml .= '<img class="mailpoet_captcha" src="' . $captchaUrl . '" width="' . $width . '" height="' . $height . '" title="' . esc_attr__('CAPTCHA', 'mailpoet') . '" />';
|
||||
$formHtml .= '</p>';
|
||||
$formHtml .= '<button type="button" class="mailpoet_icon_button mailpoet_captcha_update" title="' . esc_attr(__('Reload CAPTCHA', 'mailpoet')) . '"><img src="' . $reloadIcon . '" alt="" /></button>';
|
||||
$formHtml .= '<button type="button" class="mailpoet_icon_button mailpoet_captcha_audio" title="' . esc_attr(__('Play CAPTCHA', 'mailpoet')) . '"><img src="' . $playIcon . '" alt="" /></button>';
|
||||
$formHtml .= '<audio class="mailpoet_captcha_player">';
|
||||
$formHtml .= '<source src="' . $mp3CaptchaUrl . '" type="audio/mpeg">';
|
||||
$formHtml .= '</audio>';
|
||||
|
||||
$formHtml .= $this->formRenderer->renderBlocks($form, [], null, $honeypot = false);
|
||||
$formHtml .= '</div>';
|
||||
|
||||
if ($afterSubmitElement) {
|
||||
$formHtml .= $afterSubmitElement;
|
||||
}
|
||||
|
||||
$formHtml .= '</form>';
|
||||
|
||||
if ($styles) {
|
||||
$formHtml .= $styles;
|
||||
}
|
||||
|
||||
return $formHtml;
|
||||
}
|
||||
|
||||
private function renderFormMessages(
|
||||
FormEntity $formModel,
|
||||
$showSuccessMessage = false,
|
||||
$showErrorMessage = false
|
||||
) {
|
||||
$settings = $formModel->getSettings() ?? [];
|
||||
$errorMessage = __('The characters you entered did not match the CAPTCHA image. Please try again with this new image.', 'mailpoet');
|
||||
|
||||
$formHtml = '<div class="mailpoet_message" role="alert" aria-live="assertive">';
|
||||
$formHtml .= '<p class="mailpoet_validate_success" ' . ($showSuccessMessage ? '' : ' style="display:none;"') . '>' . $settings['success_message'] . '</p>';
|
||||
$formHtml .= '<p class="mailpoet_validate_error" ' . ($showErrorMessage ? '' : ' style="display:none;"') . '>' . $errorMessage . '</p>';
|
||||
$formHtml .= '</div>';
|
||||
|
||||
return $formHtml;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Captcha\Validator\CaptchaValidator;
|
||||
use MailPoet\Captcha\Validator\ValidationError;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
|
||||
class CaptchaHooks {
|
||||
|
||||
private SettingsController $settings;
|
||||
private CaptchaValidator $captchaValidator;
|
||||
private CaptchaRenderer $captchaRenderer;
|
||||
|
||||
public function __construct(
|
||||
SettingsController $settings,
|
||||
CaptchaValidator $captchaValidator,
|
||||
CaptchaRenderer $captchaRenderer
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->captchaValidator = $captchaValidator;
|
||||
$this->captchaRenderer = $captchaRenderer;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool {
|
||||
if (!$this->settings->get(CaptchaConstants::ON_REGISTER_FORMS_SETTING_NAME, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$type = $this->settings->get('captcha.type');
|
||||
return CaptchaConstants::isBuiltIn($type)
|
||||
|| (CaptchaConstants::isDisabled($type) && $this->captchaRenderer->isSupported());
|
||||
}
|
||||
|
||||
public function renderInWPRegisterForm() {
|
||||
$this->render('form#registerform', CaptchaUrlFactory::REFERER_WP_FORM);
|
||||
}
|
||||
|
||||
public function renderInWCRegisterForm() {
|
||||
$this->render('form.woocommerce-form-register', CaptchaUrlFactory::REFERER_WC_FORM);
|
||||
}
|
||||
|
||||
private function render($formSelector, $referrer) {
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped
|
||||
echo <<<HTML
|
||||
<input class="mailpoet_hidden_field" type="hidden" name="action" value="mailpoet">
|
||||
<input class="mailpoet_hidden_field" type="hidden" name="endpoint" value="captcha">
|
||||
<input class="mailpoet_hidden_field" type="hidden" name="method" value="render">
|
||||
<input class="mailpoet_hidden_field" type="hidden" name="api_version" value="v1">
|
||||
|
||||
<input type="hidden" name="referrer_form" value="$referrer">
|
||||
|
||||
<script async defer>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
let form = document.querySelector('$formSelector');
|
||||
|
||||
// Forward the original form action URL
|
||||
let actionUrl = form.getAttribute('action') ?? window.location.href;
|
||||
form.insertAdjacentHTML('beforeend', '<input type="hidden" name="referrer_form_url" value="' + actionUrl + '">');
|
||||
|
||||
// Submit the form to MP's AJAX endpoint
|
||||
form.setAttribute('action', '/wp-admin/admin-ajax.php');
|
||||
|
||||
// Transform 'name' attr to 'data[name]' format
|
||||
form.querySelectorAll('input,select,textarea,button[name][value]').forEach(function (field) {
|
||||
if (!field.classList.contains('mailpoet_hidden_field')) {
|
||||
field.setAttribute('name', 'data[' + field.getAttribute('name') + ']');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
HTML;
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.HeredocOutputNotEscaped
|
||||
}
|
||||
|
||||
public function validate(\WP_Error $errors) {
|
||||
try {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$this->captchaValidator->validate($_POST['data'] ?? []);
|
||||
} catch (ValidationError $e) {
|
||||
$errors->add('captcha_failed', $e->getMessage());
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoetVendor\Gregwar\Captcha\PhraseBuilder;
|
||||
|
||||
class CaptchaPhrase {
|
||||
private CaptchaSession $session;
|
||||
private PhraseBuilder $phraseBuilder;
|
||||
|
||||
public function __construct(
|
||||
CaptchaSession $session,
|
||||
PhraseBuilder $phraseBuilder = null
|
||||
) {
|
||||
$this->session = $session;
|
||||
$this->phraseBuilder = $phraseBuilder ?? new PhraseBuilder();
|
||||
}
|
||||
|
||||
public function createPhrase(string $sessionId): string {
|
||||
$storage = ['phrase' => $this->phraseBuilder->build()];
|
||||
$this->session->setCaptchaHash($sessionId, $storage);
|
||||
return $storage['phrase'];
|
||||
}
|
||||
|
||||
public function getPhrase(string $sessionId): ?string {
|
||||
$storage = $this->session->getCaptchaHash($sessionId);
|
||||
return (isset($storage['phrase']) && is_string($storage['phrase'])) ? $storage['phrase'] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\Util\Headers;
|
||||
use MailPoetVendor\Gregwar\Captcha\CaptchaBuilder;
|
||||
|
||||
class CaptchaRenderer {
|
||||
const DEFAULT_WIDTH = 220;
|
||||
const DEFAULT_HEIGHT = 60;
|
||||
|
||||
private CaptchaPhrase $phrase;
|
||||
|
||||
public function __construct(
|
||||
CaptchaPhrase $phrase
|
||||
) {
|
||||
$this->phrase = $phrase;
|
||||
}
|
||||
|
||||
public function isSupported(): bool {
|
||||
return extension_loaded('gd') && function_exists('imagettftext');
|
||||
}
|
||||
|
||||
public function renderAudio(string $sessionId): void {
|
||||
$audioPath = Env::$assetsPath . '/audio/';
|
||||
$phrase = $this->getPhrase($sessionId);
|
||||
|
||||
$files = [];
|
||||
foreach (str_split($phrase) as $character) {
|
||||
$file = $audioPath . strtolower($character) . '.mp3';
|
||||
if (!file_exists($file)) {
|
||||
throw new \RuntimeException("File not found.");
|
||||
}
|
||||
$files[] = $file;
|
||||
}
|
||||
|
||||
Headers::setNoCacheHeaders();
|
||||
header('Content-Type: audio/mpeg');
|
||||
foreach ($files as $file) {
|
||||
readfile($file);
|
||||
}
|
||||
}
|
||||
|
||||
public function renderImage(string $sessionId, $width = null, $height = null): void {
|
||||
if (!$this->isSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$width = (isset($width) && $width > 0) ? intval($width) : self::DEFAULT_WIDTH;
|
||||
$height = (isset($height) && $height > 0) ? intval($height) : self::DEFAULT_HEIGHT;
|
||||
|
||||
$fontNumbers = array_merge(range(0, 3), [5]); // skip font #4
|
||||
$fontNumber = $fontNumbers[mt_rand(0, count($fontNumbers) - 1)];
|
||||
|
||||
$reflector = new \ReflectionClass(CaptchaBuilder::class);
|
||||
$captchaDirectory = dirname((string)$reflector->getFileName());
|
||||
$font = $captchaDirectory . '/Font/captcha' . $fontNumber . '.ttf';
|
||||
|
||||
$phrase = $this->getPhrase($sessionId);
|
||||
$builder = CaptchaBuilder::create($phrase)
|
||||
->setBackgroundColor(255, 255, 255)
|
||||
->setTextColor(1, 1, 1)
|
||||
->setMaxBehindLines(0)
|
||||
->build($width, $height, $font);
|
||||
|
||||
Headers::setNoCacheHeaders();
|
||||
header('Content-Type: image/jpeg');
|
||||
$builder->output();
|
||||
}
|
||||
|
||||
public function refreshPhrase(string $sessionId): string {
|
||||
return $this->phrase->createPhrase($sessionId);
|
||||
}
|
||||
|
||||
private function getPhrase(string $sessionId): string {
|
||||
$phrase = $this->phrase->getPhrase($sessionId);
|
||||
if (!$phrase) {
|
||||
throw new \RuntimeException("No CAPTCHA phrase was generated.");
|
||||
}
|
||||
return $phrase;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Util\Security;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class CaptchaSession {
|
||||
const EXPIRATION = 1800; // 30 minutes
|
||||
const ID_LENGTH = 32;
|
||||
|
||||
const SESSION_HASH_KEY = 'hash';
|
||||
const SESSION_FORM_KEY = 'form';
|
||||
|
||||
private WPFunctions $wp;
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function generateSessionId(): string {
|
||||
return Security::generateRandomString(self::ID_LENGTH);
|
||||
}
|
||||
|
||||
public function reset(string $sessionId): void {
|
||||
$formKey = $this->getKey($sessionId, self::SESSION_FORM_KEY);
|
||||
$hashKey = $this->getKey($sessionId, self::SESSION_HASH_KEY);
|
||||
$this->wp->deleteTransient($formKey);
|
||||
$this->wp->deleteTransient($hashKey);
|
||||
}
|
||||
|
||||
public function setFormData(string $sessionId, array $data): void {
|
||||
$key = $this->getKey($sessionId, self::SESSION_FORM_KEY);
|
||||
$this->wp->setTransient($key, $data, self::EXPIRATION);
|
||||
}
|
||||
|
||||
public function getFormData(string $sessionId) {
|
||||
$key = $this->getKey($sessionId, self::SESSION_FORM_KEY);
|
||||
return $this->wp->getTransient($key);
|
||||
}
|
||||
|
||||
public function setCaptchaHash(string $sessionId, $hash): void {
|
||||
$key = $this->getKey($sessionId, self::SESSION_HASH_KEY);
|
||||
$this->wp->setTransient($key, $hash, self::EXPIRATION);
|
||||
}
|
||||
|
||||
public function getCaptchaHash(string $sessionId) {
|
||||
$key = $this->getKey($sessionId, self::SESSION_HASH_KEY);
|
||||
return $this->wp->getTransient($key);
|
||||
}
|
||||
|
||||
private function getKey(string $sessionId, string $type): string {
|
||||
return implode('_', ['MAILPOET', $sessionId, $type]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Router\Endpoints\Captcha as CaptchaEndpoint;
|
||||
use MailPoet\Router\Router;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class CaptchaUrlFactory {
|
||||
private WPFunctions $wp;
|
||||
private SettingsController $settings;
|
||||
|
||||
const REFERER_MP_FORM = 'mp_form';
|
||||
const REFERER_WP_FORM = 'wp_register_form';
|
||||
const REFERER_WC_FORM = 'wc_register_form';
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp,
|
||||
SettingsController $settings
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function getCaptchaUrl(array $data) {
|
||||
return $this->getUrl(CaptchaEndpoint::ACTION_RENDER, $data);
|
||||
}
|
||||
|
||||
public function getCaptchaUrlForMPForm(string $sessionId) {
|
||||
$data = [
|
||||
'captcha_session_id' => $sessionId,
|
||||
'referrer_form' => self::REFERER_MP_FORM,
|
||||
];
|
||||
|
||||
return $this->getCaptchaUrl($data);
|
||||
}
|
||||
|
||||
public function getCaptchaImageUrl(int $width, int $height, string $sessionId) {
|
||||
return $this->getUrl(
|
||||
CaptchaEndpoint::ACTION_IMAGE,
|
||||
[
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'captcha_session_id' => $sessionId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getCaptchaAudioUrl(string $sessionId) {
|
||||
return $this->getUrl(
|
||||
CaptchaEndpoint::ACTION_AUDIO,
|
||||
[
|
||||
'cacheBust' => time(),
|
||||
'captcha_session_id' => $sessionId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getUrl(string $action, array $data) {
|
||||
$post = $this->wp->getPost($this->settings->get('subscription.pages.captcha'));
|
||||
$url = $this->wp->getPermalink($post);
|
||||
|
||||
$params = [
|
||||
Router::NAME,
|
||||
'endpoint=' . CaptchaEndpoint::ENDPOINT,
|
||||
'action=' . $action,
|
||||
'data=' . Router::encodeRequestData($data),
|
||||
];
|
||||
|
||||
$url .= (parse_url($url, PHP_URL_QUERY) ? '&' : '?') . join('&', $params);
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Form\AssetsController;
|
||||
use MailPoet\WP\Functions as WPFunction;
|
||||
|
||||
class PageRenderer {
|
||||
private array $data = [];
|
||||
private WPFunction $wp;
|
||||
private CaptchaFormRenderer $formRenderer;
|
||||
private AssetsController $assetsController;
|
||||
|
||||
public function __construct(
|
||||
WPFunction $wp,
|
||||
CaptchaFormRenderer $formRenderer,
|
||||
AssetsController $assetsController
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
$this->formRenderer = $formRenderer;
|
||||
$this->assetsController = $assetsController;
|
||||
}
|
||||
|
||||
public function render($data) {
|
||||
$this->data = $data;
|
||||
$this->wp->addFilter('wp_title', [$this, 'setWindowTitle'], 10, 3);
|
||||
$this->wp->addFilter('document_title_parts', [$this, 'setWindowTitleParts']);
|
||||
$this->wp->removeAction('wp_head', 'noindex', 1);
|
||||
$this->wp->addAction('wp_head', [$this, 'setMetaRobots'], 1);
|
||||
$this->wp->addFilter('the_title', [$this, 'setPageTitle']);
|
||||
$this->wp->addFilter('the_content', [$this, 'setPageContent']);
|
||||
}
|
||||
|
||||
public function setWindowTitle($title, $separator, $separatorLocation = 'right') {
|
||||
$titleParts = explode(" $separator ", $title);
|
||||
if (!is_array($titleParts)) {
|
||||
return $title;
|
||||
}
|
||||
|
||||
if ($separatorLocation === 'right') {
|
||||
// first part
|
||||
$titleParts[0] = $this->setPageTitle();
|
||||
} else {
|
||||
// last part
|
||||
$lastIndex = count($titleParts) - 1;
|
||||
$titleParts[$lastIndex] = $this->setPageTitle();
|
||||
}
|
||||
|
||||
return implode(" $separator ", $titleParts);
|
||||
}
|
||||
|
||||
public function setWindowTitleParts($meta = []) {
|
||||
$meta['title'] = $this->setPageTitle();
|
||||
return $meta;
|
||||
}
|
||||
|
||||
public function setMetaRobots() {
|
||||
echo '<meta name="robots" content="noindex,nofollow">';
|
||||
}
|
||||
|
||||
public function setPageTitle() {
|
||||
return __("Confirm you’re not a robot", 'mailpoet');
|
||||
}
|
||||
|
||||
public function setPageContent($pageContent) {
|
||||
$this->assetsController->setupFrontEndDependencies();
|
||||
|
||||
$content = $this->formRenderer->render($this->data);
|
||||
if (!$content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_replace('[mailpoet_page]', trim($content), $pageContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Config\Env;
|
||||
use MailPoet\Config\Renderer as BasicRenderer;
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class ReCaptchaHooks {
|
||||
|
||||
const RECAPTCHA_LIB_URL = 'https://www.google.com/recaptcha/api.js';
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var BasicRenderer */
|
||||
private $renderer;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var ReCaptchaValidator */
|
||||
private $reCaptchaValidator;
|
||||
|
||||
/** @var ReCaptchaRenderer */
|
||||
private $reCaptchaRenderer;
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp,
|
||||
BasicRenderer $renderer,
|
||||
SettingsController $settings,
|
||||
ReCaptchaValidator $reCaptchaValidator,
|
||||
ReCaptchaRenderer $reCaptchaRenderer
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
$this->renderer = $renderer;
|
||||
$this->settings = $settings;
|
||||
$this->reCaptchaValidator = $reCaptchaValidator;
|
||||
$this->reCaptchaRenderer = $reCaptchaRenderer;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool {
|
||||
if (!$this->settings->get(CaptchaConstants::ON_REGISTER_FORMS_SETTING_NAME, false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return CaptchaConstants::isReCaptcha(
|
||||
$this->settings->get('captcha.type')
|
||||
);
|
||||
}
|
||||
|
||||
public function enqueueScripts() {
|
||||
$this->wp->wpEnqueueScript('mailpoet_recaptcha', self::RECAPTCHA_LIB_URL);
|
||||
|
||||
$this->wp->wpEnqueueStyle(
|
||||
'mailpoet_public',
|
||||
Env::$assetsUrl . '/dist/css/' . $this->renderer->getCssAsset('mailpoet-public.css')
|
||||
);
|
||||
|
||||
$this->wp->wpEnqueueScript(
|
||||
'mailpoet_public',
|
||||
Env::$assetsUrl . '/dist/js/' . $this->renderer->getJsAsset('public.js'),
|
||||
['jquery'],
|
||||
Env::$version,
|
||||
[
|
||||
'in_footer' => true,
|
||||
'strategy' => 'defer',
|
||||
]
|
||||
);
|
||||
|
||||
// necessary for public.js script
|
||||
$ajaxFailedErrorMessage = __('An error has happened while performing a request, please try again later.', 'mailpoet');
|
||||
$this->wp->wpLocalizeScript('mailpoet_public', 'MailPoetForm', [
|
||||
'ajax_url' => $this->wp->adminUrl('admin-ajax.php'),
|
||||
'is_rtl' => (function_exists('is_rtl') && is_rtl()),
|
||||
'ajax_common_error_message' => esc_js($ajaxFailedErrorMessage),
|
||||
]);
|
||||
}
|
||||
|
||||
public function render() {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo $this->reCaptchaRenderer->render();
|
||||
}
|
||||
|
||||
public function validate(\WP_Error $errors) {
|
||||
try {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$responseToken = $_POST['g-recaptcha-response'] ?? '';
|
||||
$this->reCaptchaValidator->validate($responseToken);
|
||||
} catch (\Throwable $e) {
|
||||
$errors->add('recaptcha_failed', $e->getMessage());
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class ReCaptchaRenderer {
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
public function __construct(
|
||||
SettingsController $settings,
|
||||
WPFunctions $wp
|
||||
) {
|
||||
$this->settings = $settings;
|
||||
$this->wp = $wp;
|
||||
}
|
||||
|
||||
public function render(): string {
|
||||
$captchaSettings = $this->settings->get('captcha');
|
||||
$isInvisible = $captchaSettings['type'] === CaptchaConstants::TYPE_RECAPTCHA_INVISIBLE;
|
||||
|
||||
if ($isInvisible) {
|
||||
$siteKey = $this->wp->escAttr($captchaSettings['recaptcha_invisible_site_token']);
|
||||
$html = '<div class="g-recaptcha" data-size="invisible" data-callback="onInvisibleReCaptchaSubmit" data-sitekey="' . $siteKey . '"></div>';
|
||||
} else {
|
||||
$siteKey = $this->wp->escAttr($captchaSettings['recaptcha_site_token']);
|
||||
$html = '<div class="g-recaptcha" data-sitekey="' . $siteKey . '"></div>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php declare(strict_types = 1);
|
||||
|
||||
namespace MailPoet\Captcha;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Settings\SettingsController;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class ReCaptchaValidator {
|
||||
|
||||
private const ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var SettingsController */
|
||||
private $settings;
|
||||
|
||||
public function __construct(
|
||||
WPFunctions $wp,
|
||||
SettingsController $settings
|
||||
) {
|
||||
$this->wp = $wp;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception response token is missing or invalid.
|
||||
*/
|
||||
public function validate(string $responseToken) {
|
||||
$captchaSettings = $this->settings->get('captcha');
|
||||
if (empty($responseToken)) {
|
||||
throw new \Exception(__('Please check the CAPTCHA.', 'mailpoet'));
|
||||
}
|
||||
|
||||
$secretToken = $captchaSettings['type'] === CaptchaConstants::TYPE_RECAPTCHA
|
||||
? $captchaSettings['recaptcha_secret_token']
|
||||
: $captchaSettings['recaptcha_invisible_secret_token'];
|
||||
|
||||
$response = $this->wp->wpRemotePost(self::ENDPOINT, [
|
||||
'body' => [
|
||||
'secret' => $secretToken,
|
||||
'response' => $responseToken,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($this->wp->isWpError($response)) {
|
||||
throw new \Exception(__('Error while validating the CAPTCHA.', 'mailpoet'));
|
||||
}
|
||||
|
||||
/** @var \stdClass $response */
|
||||
$response = json_decode($this->wp->wpRemoteRetrieveBody($response));
|
||||
if ($response === null) { // invalid JSON
|
||||
throw new \Exception(__('Error while validating the CAPTCHA.', 'mailpoet'));
|
||||
} else if (empty($response->success)) { // missing or false
|
||||
throw new \Exception(__('Invalid CAPTCHA. Try again.', 'mailpoet'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Captcha\Validator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Captcha\CaptchaPhrase;
|
||||
use MailPoet\Captcha\CaptchaUrlFactory;
|
||||
use MailPoet\Subscribers\SubscriberIPsRepository;
|
||||
use MailPoet\Subscribers\SubscribersRepository;
|
||||
use MailPoet\Util\Helpers;
|
||||
use MailPoet\WP\Functions as WPFunctions;
|
||||
|
||||
class CaptchaValidator {
|
||||
/** @var CaptchaUrlFactory */
|
||||
private $captchaUrlFactory;
|
||||
|
||||
/** @var CaptchaPhrase */
|
||||
private $captchaPhrase;
|
||||
|
||||
/** @var WPFunctions */
|
||||
private $wp;
|
||||
|
||||
/** @var SubscriberIPsRepository */
|
||||
private $subscriberIPsRepository;
|
||||
|
||||
/** @var SubscribersRepository */
|
||||
private $subscribersRepository;
|
||||
|
||||
public function __construct(
|
||||
CaptchaUrlFactory $urlFactory,
|
||||
CaptchaPhrase $captchaPhrase,
|
||||
WPFunctions $wp,
|
||||
SubscriberIPsRepository $subscriberIPsRepository,
|
||||
SubscribersRepository $subscribersRepository
|
||||
) {
|
||||
$this->captchaUrlFactory = $urlFactory;
|
||||
$this->captchaPhrase = $captchaPhrase;
|
||||
$this->wp = $wp;
|
||||
$this->subscriberIPsRepository = $subscriberIPsRepository;
|
||||
$this->subscribersRepository = $subscribersRepository;
|
||||
}
|
||||
|
||||
public function validate(array $data): bool {
|
||||
$isBuiltinCaptchaRequired = $this->isRequired(isset($data['email']) ? $data['email'] : null);
|
||||
if (!$isBuiltinCaptchaRequired) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// session ID must be set at this point
|
||||
$sessionId = $data['captcha_session_id'] ?? null;
|
||||
if (!$sessionId) {
|
||||
throw new ValidationError(__('CAPTCHA verification failed.', 'mailpoet'));
|
||||
}
|
||||
|
||||
if (empty($data['captcha'])) {
|
||||
throw new ValidationError(
|
||||
__('Please fill in the CAPTCHA.', 'mailpoet'),
|
||||
[
|
||||
'redirect_url' => $this->captchaUrlFactory->getCaptchaUrlForMPForm($sessionId),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$captchaHash = $this->captchaPhrase->getPhrase($sessionId);
|
||||
if (empty($captchaHash)) {
|
||||
throw new ValidationError(
|
||||
__('Please regenerate the CAPTCHA.', 'mailpoet'),
|
||||
[
|
||||
'redirect_url' => $this->captchaUrlFactory->getCaptchaUrlForMPForm($sessionId),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (!hash_equals(strtolower($data['captcha']), strtolower($captchaHash))) {
|
||||
$this->captchaPhrase->createPhrase($sessionId);
|
||||
throw new ValidationError(
|
||||
__('The characters entered do not match with the previous CAPTCHA.', 'mailpoet'),
|
||||
[
|
||||
'refresh_captcha' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isRequired($subscriberEmail = null) {
|
||||
if ($this->isUserExemptFromCaptcha()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriptionCaptchaRecipientLimit = $this->wp->applyFilters('mailpoet_subscription_captcha_recipient_limit', 0);
|
||||
if ($subscriptionCaptchaRecipientLimit === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check limits per recipient if enabled
|
||||
if ($subscriberEmail) {
|
||||
$subscriber = $this->subscribersRepository->findOneBy(['email' => $subscriberEmail]);
|
||||
if (
|
||||
$subscriber && $subscriber->getConfirmationsCount() >= $subscriptionCaptchaRecipientLimit
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check limits per IP address
|
||||
/** @var int|string $subscriptionCaptchaWindow */
|
||||
$subscriptionCaptchaWindow = $this->wp->applyFilters('mailpoet_subscription_captcha_window', MONTH_IN_SECONDS);
|
||||
|
||||
$subscriberIp = Helpers::getIP();
|
||||
if (empty($subscriberIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subscriptionCount = $this->subscriberIPsRepository->getCountByIPAndCreatedAtAfterTimeInSeconds(
|
||||
$subscriberIp,
|
||||
(int)$subscriptionCaptchaWindow
|
||||
);
|
||||
|
||||
if ($subscriptionCount > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isUserExemptFromCaptcha() {
|
||||
if (!$this->wp->isUserLoggedIn()) {
|
||||
return false;
|
||||
}
|
||||
$user = $this->wp->wpGetCurrentUser();
|
||||
$roles = $this->wp->applyFilters('mailpoet_subscription_captcha_exclude_roles', ['administrator', 'editor']);
|
||||
return !empty(array_intersect((array)$roles, $user->roles));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Captcha\Validator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
use MailPoet\Captcha\ReCaptchaValidator as Validator;
|
||||
|
||||
class RecaptchaValidator {
|
||||
|
||||
/** @var Validator */
|
||||
private $validator;
|
||||
|
||||
public function __construct(
|
||||
Validator $validator
|
||||
) {
|
||||
$this->validator = $validator;
|
||||
}
|
||||
|
||||
public function validate(array $data): bool {
|
||||
$token = $data['recaptchaResponseToken'] ?? '';
|
||||
|
||||
try {
|
||||
$this->validator->validate($token);
|
||||
} catch (\Exception $e) {
|
||||
throw new ValidationError($e->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing
|
||||
|
||||
namespace MailPoet\Captcha\Validator;
|
||||
|
||||
if (!defined('ABSPATH')) exit;
|
||||
|
||||
|
||||
class ValidationError extends \RuntimeException {
|
||||
|
||||
private $meta = [];
|
||||
|
||||
public function __construct(
|
||||
$message = "",
|
||||
array $meta = [],
|
||||
$code = 0,
|
||||
\Throwable $previous = null
|
||||
) {
|
||||
$this->meta = $meta;
|
||||
$this->meta['error'] = $message;
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function getMeta(): array {
|
||||
return $this->meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
Reference in New Issue
Block a user