Skip to content

Commit

Permalink
Merge branch 'feature/cloudflare-turnstile' into 2.x
Browse files Browse the repository at this point in the history
  • Loading branch information
bencroker committed Jun 18, 2024
2 parents 180230b + 23042f2 commit 11d7bd5
Show file tree
Hide file tree
Showing 17 changed files with 396 additions and 42 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Release Notes for Campaign

## 2.16.0 - Unreleased

### Added

- Added the ability to enforce spam prevention on front-end forms using Cloudflare Turnstile ([#447](https://github.com/putyourlightson/craft-campaign/issues/447)).

## 2.15.3 - 2024-05-06

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "putyourlightson/craft-campaign",
"description": "Send and manage email campaigns, contacts and mailing lists.",
"version": "2.15.3",
"version": "2.16.0",
"type": "craft-plugin",
"homepage": "https://putyourlightson.com/plugins/campaign",
"license": "proprietary",
Expand Down
3 changes: 2 additions & 1 deletion src/Campaign.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ public function log(string $message, array $params = [], int $type = Logger::LEV
/**
* Returns whether the current user can edit contacts.
*/
public function userCanEditContacts(): bool
public function canUserEditContacts(): bool
{
/** @var User|null $currentUser */
$currentUser = Craft::$app->getUser()->getIdentity();
Expand Down Expand Up @@ -455,6 +455,7 @@ protected function getCpRoutes(): array
'campaign/settings/contact' => 'campaign/settings/edit-contact',
'campaign/settings/geoip' => 'campaign/settings/edit-geoip',
'campaign/settings/recaptcha' => 'campaign/settings/edit-recaptcha',
'campaign/settings/turnstile' => 'campaign/settings/edit-turnstile',
'campaign/settings/campaigntypes' => 'campaign/campaign-types/index',
'campaign/settings/campaigntypes/new' => 'campaign/campaign-types/edit',
'campaign/settings/campaigntypes/<campaignTypeId:\d+>' => 'campaign/campaign-types/edit',
Expand Down
26 changes: 22 additions & 4 deletions src/controllers/FormsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
namespace putyourlightson\campaign\controllers;

use Craft;
use craft\helpers\App;
use putyourlightson\campaign\base\BaseMessageController;
use putyourlightson\campaign\Campaign;
use putyourlightson\campaign\elements\ContactElement;
use putyourlightson\campaign\elements\MailingListElement;
use putyourlightson\campaign\helpers\RecaptchaHelper;
use putyourlightson\campaign\helpers\TurnstileHelper;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
Expand All @@ -30,6 +30,7 @@ public function actionSubscribe(): ?Response
{
$this->requirePostRequest();
$this->validateRecaptcha();
$this->validateTurnstile();

$mailingList = $this->getMailingListFromParams();
if ($mailingList === null) {
Expand Down Expand Up @@ -70,6 +71,7 @@ public function actionUnsubscribe(): ?Response
{
$this->requirePostRequest();
$this->validateRecaptcha();
$this->validateTurnstile();

$mailingList = $this->getMailingListFromParams();
if ($mailingList === null) {
Expand Down Expand Up @@ -103,6 +105,7 @@ public function actionUpdateContact(): ?Response
{
$this->requirePostRequest();
$this->validateRecaptcha();
$this->validateTurnstile();

// Get verified contact
$contact = $this->getVerifiedContact();
Expand Down Expand Up @@ -249,22 +252,37 @@ private function getMailingListFromParams(): ?MailingListElement
}

/**
* Validates reCAPTCHA if enabled.
* Validates reCAPTCHA, if enabled.
*/
private function validateRecaptcha(): void
{
// Validate reCAPTCHA if enabled
if (Campaign::$plugin->settings->reCaptcha) {
$response = $this->request->getParam('g-recaptcha-response');

if ($response === null) {
throw new ForbiddenHttpException(App::parseEnv(Campaign::$plugin->settings->reCaptchaErrorMessage));
throw new ForbiddenHttpException(Campaign::$plugin->settings->getRecaptchaErrorMessage());
}

RecaptchaHelper::validateRecaptcha($response, $this->request->getRemoteIP());
}
}

/**
* Validates Turnstile, if enabled.
*/
private function validateTurnstile(): void
{
if (Campaign::$plugin->settings->turnstile) {
$response = $this->request->getParam('cf-turnstile-response');

if ($response === null) {
throw new ForbiddenHttpException(Campaign::$plugin->settings->getTurnstileErrorMessage());
}

TurnstileHelper::validate($response, $this->request->getRemoteIP());
}
}

/**
* Gets contact by CID, verified by UID.
*/
Expand Down
3 changes: 1 addition & 2 deletions src/controllers/SendoutsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
use Craft;
use craft\base\Element;
use craft\errors\SiteNotFoundException;
use craft\helpers\App;
use craft\helpers\Cp;
use craft\helpers\ElementHelper;
use craft\helpers\UrlHelper;
Expand Down Expand Up @@ -57,7 +56,7 @@ public function actionQueuePendingSendouts(): Response
} else {
// Verify API key
$key = $this->request->getParam('key');
$apiKey = App::parseEnv(Campaign::$plugin->settings->apiKey);
$apiKey = Campaign::$plugin->settings->getApiKey();

if ($key === null || empty($apiKey) || $key != $apiKey) {
throw new ForbiddenHttpException('Unauthorised access.');
Expand Down
40 changes: 40 additions & 0 deletions src/controllers/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ public function actionEditRecaptcha(SettingsModel $settings = null): Response
]);
}

/**
* Edit Turnstile settings.
*
* @param SettingsModel|null $settings The settings being edited, if there were any validation errors.
*/
public function actionEditTurnstile(SettingsModel $settings = null): Response
{
if ($settings === null) {
$settings = Campaign::$plugin->settings;
}

return $this->renderTemplate('campaign/_settings/turnstile', [
'settings' => $settings,
'config' => Craft::$app->getConfig()->getConfigFromFile('campaign'),
]);
}

/**
* Saves general settings.
*/
Expand Down Expand Up @@ -319,6 +336,29 @@ public function actionSaveRecaptcha(): ?Response
return $this->asSuccess(Craft::t('campaign', 'reCAPTCHA settings saved.'));
}

/**
* Saves Turnstile settings.
*/
public function actionSaveTurnstile(): ?Response
{
$this->requirePostRequest();

$settings = Campaign::$plugin->settings;

// Set the simple stuff
$settings->turnstile = $this->request->getBodyParam('turnstile', $settings->turnstile);
$settings->turnstileSiteKey = $this->request->getBodyParam('turnstileSiteKey', $settings->turnstileSiteKey);
$settings->turnstileSecretKey = $this->request->getBodyParam('turnstileSecretKey', $settings->turnstileSecretKey);
$settings->turnstileErrorMessage = $this->request->getBodyParam('turnstileErrorMessage', $settings->turnstileErrorMessage);

// Save it
if (!Craft::$app->getPlugins()->savePluginSettings(Campaign::$plugin, $settings->getAttributes())) {
return $this->asModelFailure($settings, Craft::t('campaign', 'Couldn’t save Turnstile settings.'), 'settings');
}

return $this->asSuccess(Craft::t('campaign', 'Turnstile settings saved.'));
}

/**
* Sends a test email.
*/
Expand Down
9 changes: 4 additions & 5 deletions src/controllers/WebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Aws\Sns\Message;
use Aws\Sns\MessageValidator;
use Craft;
use craft\helpers\App;
use craft\helpers\Json;
use craft\web\Controller;
use EllipticCurve\Ecdsa;
Expand Down Expand Up @@ -54,7 +53,7 @@ public function beforeAction($action): bool
{
// Verify API key
$key = $this->request->getParam('key');
$apiKey = App::parseEnv(Campaign::$plugin->settings->apiKey);
$apiKey = Campaign::$plugin->settings->getApiKey();

if ($key === null || empty($apiKey) || $key != $apiKey) {
throw new ForbiddenHttpException('Unauthorised access.');
Expand Down Expand Up @@ -410,7 +409,7 @@ private function isValidMailersendRequest(string $body): bool
return true;
}

$signingSecret = (string)App::parseEnv(Campaign::$plugin->settings->mailersendWebhookSigningSecret);
$signingSecret = Campaign::$plugin->settings->getMailersendWebhookSigningSecret();
$signature = $this->request->headers->get('Signature', '');
$hashedValue = hash_hmac('sha256', $body, $signingSecret);

Expand All @@ -426,7 +425,7 @@ private function isValidMailgunRequest(string $signature, string $timestamp, str
return true;
}

$signingKey = (string)App::parseEnv(Campaign::$plugin->settings->mailgunWebhookSigningKey);
$signingKey = Campaign::$plugin->settings->getMailgunWebhookSigningSecret();
$hashedValue = hash_hmac('sha256', $timestamp . $token, $signingKey);

return hash_equals($signature, $hashedValue);
Expand All @@ -443,7 +442,7 @@ private function isValidSendgridRequest(string $body): bool

$signature = $this->request->headers->get('X-Twilio-Email-Event-Webhook-Signature', '');
$timestamp = $this->request->headers->get('X-Twilio-Email-Event-Webhook-Timestamp', '');
$verificationKey = (string)App::parseEnv(Campaign::$plugin->settings->sendgridWebhookVerificationKey);
$verificationKey = Campaign::$plugin->settings->getSendgridWebhookSigningSecret();

// https://github.com/sendgrid/sendgrid-php/blob/9335dca98bc64456a72db73469d1dd67db72f6ea/lib/eventwebhook/EventWebhook.php#L23-L26
$publicKey = PublicKey::fromString($verificationKey);
Expand Down
2 changes: 1 addition & 1 deletion src/elements/ContactElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ protected function statusFieldHtml(): string
*/
public function canView(User $user): bool
{
if (!Campaign::$plugin->userCanEditContacts()) {
if (!Campaign::$plugin->canUserEditContacts()) {
return false;
}

Expand Down
3 changes: 1 addition & 2 deletions src/helpers/ContactActivityHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
namespace putyourlightson\campaign\helpers;

use Craft;
use craft\helpers\App;
use craft\helpers\Json;
use DateTime;
use DeviceDetector\DeviceDetector;
Expand Down Expand Up @@ -83,7 +82,7 @@ public static function getGeoIp(int $timeout = 5): ?array

try {
$ip = Craft::$app->getRequest()->getRemoteIP();
$apiKey = App::parseEnv(Campaign::$plugin->settings->ipstackApiKey);
$apiKey = Campaign::$plugin->settings->getIpstackApiKey();

/** @noinspection HttpUrlsUsage */
$response = $client->get('http://api.ipstack.com/' . $ip . '?access_key=' . $apiKey);
Expand Down
9 changes: 4 additions & 5 deletions src/helpers/RecaptchaHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
namespace putyourlightson\campaign\helpers;

use Craft;
use craft\helpers\App;
use craft\helpers\Json;
use GuzzleHttp\Exception\ConnectException;
use putyourlightson\campaign\Campaign;
Expand Down Expand Up @@ -39,24 +38,24 @@ public static function validateRecaptcha(string $recaptchaResponse, string $ip):
try {
$response = $client->post('https://www.google.com/recaptcha/api/siteverify', [
'form_params' => [
'secret' => App::parseEnv($settings->reCaptchaSecretKey),
'secret' => $settings->getRecaptchaSecretKey(),
'response' => $recaptchaResponse,
'remoteip' => $ip,
],
]);

if ($response->getStatusCode() == 200) {
if ($response->getStatusCode() === 200) {
$result = Json::decodeIfJson($response->getBody());
}
} catch (ConnectException) {
}

if (empty($result['success'])) {
throw new ForbiddenHttpException(App::parseEnv($settings->reCaptchaErrorMessage));
throw new ForbiddenHttpException($settings->reCaptchaErrorMessage);
}

if (!empty($result['action']) && $result['action'] != self::RECAPTCHA_ACTION) {
throw new ForbiddenHttpException(App::parseEnv($settings->reCaptchaErrorMessage));
throw new ForbiddenHttpException($settings->getRecaptchaErrorMessage());
}
}
}
59 changes: 59 additions & 0 deletions src/helpers/TurnstileHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php
/**
* @copyright Copyright (c) PutYourLightsOn
*/

namespace putyourlightson\campaign\helpers;

use Craft;
use craft\helpers\Json;
use GuzzleHttp\Exception\ConnectException;
use putyourlightson\campaign\Campaign;
use yii\web\ForbiddenHttpException;

/**
* @since 2.16.0
*/
class TurnstileHelper
{
/**
* @const string
*/
public const TURNSTILE_ACTION = 'homepage';

/**
* Validates the response.
*/
public static function validate(string $response, string $ip): void
{
$settings = Campaign::$plugin->settings;

$result = '';

$client = Craft::createGuzzleClient([
'timeout' => 5,
'connect_timeout' => 5,
]);

try {
$response = $client->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'form_params' => [
'secret' => $settings->getTurnstileSecretKey(),
'response' => $response,
'remoteip' => $ip,
],
]);

if ($response->getStatusCode() === 200) {
$result = Json::decodeIfJson($response->getBody());
}
} catch (ConnectException) {
}

$success = $result['success'] ?? false;

if (!$success) {
throw new ForbiddenHttpException($settings->getTurnstileErrorMessage());
}
}
}
Loading

0 comments on commit 11d7bd5

Please sign in to comment.