Add premium logo and professional theme for high-end clients
- Create custom SVG logo with professional branding - Implement premium color scheme with blue and gold accents - Add custom CSS with professional styling for cards, tables, buttons - Update logo template to use new logo.svg file - Create custom favicon for complete branding - Redesign homepage with premium content sections - Update resources page with membership tiers and premium pricing - Enhance contact page with testimonials and detailed information - Target audience: high-paying clients ($100+/hour) - Professional yet approachable design language 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush <crush@charm.land>
This commit is contained in:
596
config/www/user/plugins/form/classes/Captcha/BasicCaptcha.php
Normal file
596
config/www/user/plugins/form/classes/Captcha/BasicCaptcha.php
Normal file
@@ -0,0 +1,596 @@
|
||||
<?php
|
||||
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
|
||||
class BasicCaptcha
|
||||
{
|
||||
protected $session = null;
|
||||
protected $key = 'basic_captcha_value';
|
||||
protected $typeKey = 'basic_captcha_type';
|
||||
protected $config = null;
|
||||
|
||||
public function __construct($fieldConfig = null)
|
||||
{
|
||||
$this->session = Grav::instance()['session'];
|
||||
|
||||
// Load global configuration
|
||||
$globalConfig = Grav::instance()['config']->get('plugins.form.basic_captcha', []);
|
||||
|
||||
// Merge field-specific config with global config
|
||||
if ($fieldConfig && is_array($fieldConfig)) {
|
||||
$this->config = array_replace_recursive($globalConfig, $fieldConfig);
|
||||
} else {
|
||||
$this->config = $globalConfig;
|
||||
}
|
||||
}
|
||||
|
||||
public function getCaptchaCode($length = null): string
|
||||
{
|
||||
// Support both 'type' (from global config) and 'captcha_type' (from field config)
|
||||
$type = $this->config['captcha_type'] ?? $this->config['type'] ?? 'characters';
|
||||
|
||||
// Store the captcha type in session for validation
|
||||
$this->setSession($this->typeKey, $type);
|
||||
|
||||
switch ($type) {
|
||||
case 'dotcount':
|
||||
return $this->getDotCountCaptcha($this->config);
|
||||
case 'position':
|
||||
return $this->getPositionCaptcha($this->config);
|
||||
case 'math':
|
||||
return $this->getMathCaptcha($this->config);
|
||||
case 'characters':
|
||||
default:
|
||||
return $this->getCharactersCaptcha($this->config, $length);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dot counting captcha - user has to count dots of a specific color
|
||||
*/
|
||||
protected function getDotCountCaptcha($config): string
|
||||
{
|
||||
// Define colors with names
|
||||
$colors = [
|
||||
'red' => [255, 0, 0],
|
||||
'blue' => [0, 0, 255],
|
||||
'green' => [0, 128, 0],
|
||||
'yellow' => [255, 255, 0],
|
||||
'purple' => [128, 0, 128],
|
||||
'orange' => [255, 165, 0]
|
||||
];
|
||||
|
||||
// Pick a random color to count
|
||||
$colorNames = array_keys($colors);
|
||||
$targetColorName = $colorNames[array_rand($colorNames)];
|
||||
$targetColor = $colors[$targetColorName];
|
||||
|
||||
// Generate a random number of dots for the target color (between 5-10)
|
||||
$targetCount = mt_rand(5, 10);
|
||||
|
||||
// Store the expected answer
|
||||
$this->setSession($this->key, (string) $targetCount);
|
||||
|
||||
// Return description text
|
||||
return "count_dots|{$targetColorName}|".implode(',', $targetColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a position-based captcha - user has to identify position of a symbol
|
||||
*/
|
||||
protected function getPositionCaptcha($config): string
|
||||
{
|
||||
// Define possible symbols - using simple ASCII characters
|
||||
$symbols = ['*', '+', '$', '#', '@', '!', '?', '%', '&', '='];
|
||||
|
||||
// Define positions - simpler options
|
||||
$positions = ['top', 'bottom', 'left', 'right', 'center'];
|
||||
|
||||
// Pick a random symbol and position
|
||||
$targetSymbol = $symbols[array_rand($symbols)];
|
||||
$targetPosition = $positions[array_rand($positions)];
|
||||
|
||||
// Store the expected answer
|
||||
$this->setSession($this->key, $targetPosition);
|
||||
|
||||
// Return the instruction and symbol
|
||||
return "position|{$targetSymbol}|{$targetPosition}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a math-based captcha
|
||||
*/
|
||||
protected function getMathCaptcha($config): string
|
||||
{
|
||||
$min = $config['math']['min'] ?? 1;
|
||||
$max = $config['math']['max'] ?? 12;
|
||||
$operators = $config['math']['operators'] ?? ['+', '-', '*'];
|
||||
|
||||
$first_num = random_int($min, $max);
|
||||
$second_num = random_int($min, $max);
|
||||
$operator = $operators[array_rand($operators)];
|
||||
|
||||
// Calculator
|
||||
if ($operator === '-') {
|
||||
if ($first_num < $second_num) {
|
||||
$result = "$second_num - $first_num";
|
||||
$captcha_code = $second_num - $first_num;
|
||||
} else {
|
||||
$result = "$first_num - $second_num";
|
||||
$captcha_code = $first_num - $second_num;
|
||||
}
|
||||
} elseif ($operator === '*') {
|
||||
$result = "{$first_num} x {$second_num}";
|
||||
$captcha_code = $first_num * $second_num;
|
||||
} elseif ($operator === '+') {
|
||||
$result = "$first_num + $second_num";
|
||||
$captcha_code = $first_num + $second_num;
|
||||
}
|
||||
|
||||
$this->setSession($this->key, (string) $captcha_code);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a character-based captcha
|
||||
*/
|
||||
protected function getCharactersCaptcha($config, $length = null): string
|
||||
{
|
||||
if ($length === null) {
|
||||
$length = $config['chars']['length'] ?? 6;
|
||||
}
|
||||
|
||||
// Use more complex character set with mixed case and exclude similar-looking characters
|
||||
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
$captcha_code = '';
|
||||
|
||||
// Generate random characters
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$captcha_code .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
$this->setSession($this->key, $captcha_code);
|
||||
return $captcha_code;
|
||||
}
|
||||
|
||||
public function setSession($key, $value): void
|
||||
{
|
||||
$this->session->$key = $value;
|
||||
}
|
||||
|
||||
public function getSession($key = null): ?string
|
||||
{
|
||||
if ($key === null) {
|
||||
$key = $this->key;
|
||||
}
|
||||
return $this->session->$key ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create captcha image based on the type
|
||||
*/
|
||||
public function createCaptchaImage($captcha_code)
|
||||
{
|
||||
// Determine image dimensions based on type
|
||||
$isCharacterCaptcha = false;
|
||||
if (strpos($captcha_code, '|') === false && !preg_match('/[\+\-x]/', $captcha_code)) {
|
||||
$isCharacterCaptcha = true;
|
||||
}
|
||||
|
||||
// Use box_width/box_height for character captchas if specified, otherwise use default image dimensions
|
||||
if ($isCharacterCaptcha && isset($this->config['chars']['box_width'])) {
|
||||
$width = $this->config['chars']['box_width'];
|
||||
} else {
|
||||
$width = $this->config['image']['width'] ?? 135;
|
||||
}
|
||||
|
||||
if ($isCharacterCaptcha && isset($this->config['chars']['box_height'])) {
|
||||
$height = $this->config['chars']['box_height'];
|
||||
} else {
|
||||
$height = $this->config['image']['height'] ?? 40;
|
||||
}
|
||||
|
||||
// Create a blank image
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
|
||||
// Set background color (support both image.bg and chars.bg for character captchas)
|
||||
$bgColor = '#ffffff';
|
||||
if ($isCharacterCaptcha && isset($this->config['chars']['bg'])) {
|
||||
$bgColor = $this->config['chars']['bg'];
|
||||
} elseif (isset($this->config['image']['bg'])) {
|
||||
$bgColor = $this->config['image']['bg'];
|
||||
}
|
||||
|
||||
$bg = $this->hexToRgb($bgColor);
|
||||
$backgroundColor = imagecolorallocate($image, $bg[0], $bg[1], $bg[2]);
|
||||
imagefill($image, 0, 0, $backgroundColor);
|
||||
|
||||
// Parse the captcha code to determine type
|
||||
if (strpos($captcha_code, '|') !== false) {
|
||||
$parts = explode('|', $captcha_code);
|
||||
$type = $parts[0];
|
||||
|
||||
switch ($type) {
|
||||
case 'count_dots':
|
||||
return $this->createDotCountImage($image, $parts, $this->config);
|
||||
case 'position':
|
||||
return $this->createPositionImage($image, $parts, $this->config);
|
||||
}
|
||||
} else {
|
||||
// Assume it's a character or math captcha if no type indicator
|
||||
if (preg_match('/[\+\-x]/', $captcha_code)) {
|
||||
return $this->createMathImage($image, $captcha_code, $this->config);
|
||||
} else {
|
||||
return $this->createCharacterImage($image, $captcha_code, $this->config);
|
||||
}
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create image for dot counting captcha
|
||||
*/
|
||||
protected function createDotCountImage($image, $parts, $config)
|
||||
{
|
||||
$colorName = $parts[1];
|
||||
$targetColorRGB = explode(',', $parts[2]);
|
||||
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
// Allocate target color
|
||||
$targetColor = imagecolorallocate($image, $targetColorRGB[0], $targetColorRGB[1], $targetColorRGB[2]);
|
||||
|
||||
// Create other distraction colors
|
||||
$distractionColors = [];
|
||||
$colorOptions = [
|
||||
[255, 0, 0], // red
|
||||
[0, 0, 255], // blue
|
||||
[0, 128, 0], // green
|
||||
[255, 255, 0], // yellow
|
||||
[128, 0, 128], // purple
|
||||
[255, 165, 0] // orange
|
||||
];
|
||||
|
||||
foreach ($colorOptions as $rgb) {
|
||||
if ($rgb[0] != $targetColorRGB[0] || $rgb[1] != $targetColorRGB[1] || $rgb[2] != $targetColorRGB[2]) {
|
||||
$distractionColors[] = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get target count from session
|
||||
$targetCount = (int) $this->getSession();
|
||||
|
||||
// Draw instruction text
|
||||
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
|
||||
$black = imagecolorallocate($image, 0, 0, 0);
|
||||
imagettftext($image, 10, 0, 5, 15, $black, $fontPath, "Count {$colorName}:");
|
||||
|
||||
// Simplified approach to prevent overlapping
|
||||
// Divide the image into a grid and place one dot per cell
|
||||
$gridCells = [];
|
||||
$gridRows = 2;
|
||||
$gridCols = 4;
|
||||
|
||||
// Build available grid cells
|
||||
for ($y = 0; $y < $gridRows; $y++) {
|
||||
for ($x = 0; $x < $gridCols; $x++) {
|
||||
$gridCells[] = [$x, $y];
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle grid cells for random placement
|
||||
shuffle($gridCells);
|
||||
|
||||
// Calculate cell dimensions
|
||||
$cellWidth = ($width - 20) / $gridCols;
|
||||
$cellHeight = ($height - 20) / $gridRows;
|
||||
|
||||
// Dot size for better visibility
|
||||
$dotSize = 8;
|
||||
|
||||
// Draw target dots first (taking the first N cells)
|
||||
for ($i = 0; $i < $targetCount && $i < count($gridCells); $i++) {
|
||||
$cell = $gridCells[$i];
|
||||
$gridX = $cell[0];
|
||||
$gridY = $cell[1];
|
||||
|
||||
// Calculate center position of cell with small random offset
|
||||
$x = 10 + ($gridX + 0.5) * $cellWidth + mt_rand(-2, 2);
|
||||
$y = 20 + ($gridY + 0.5) * $cellHeight + mt_rand(-2, 2);
|
||||
|
||||
// Draw the dot
|
||||
imagefilledellipse($image, $x, $y, $dotSize, $dotSize, $targetColor);
|
||||
|
||||
// Add a small border for better contrast
|
||||
imageellipse($image, $x, $y, $dotSize + 2, $dotSize + 2, $black);
|
||||
}
|
||||
|
||||
// Draw distraction dots using remaining grid cells
|
||||
$distractionCount = min(mt_rand(8, 15), count($gridCells) - $targetCount);
|
||||
|
||||
for ($i = 0; $i < $distractionCount; $i++) {
|
||||
// Get the next available cell
|
||||
$cellIndex = $targetCount + $i;
|
||||
|
||||
if ($cellIndex >= count($gridCells)) {
|
||||
break; // No more cells available
|
||||
}
|
||||
|
||||
$cell = $gridCells[$cellIndex];
|
||||
$gridX = $cell[0];
|
||||
$gridY = $cell[1];
|
||||
|
||||
// Calculate center position of cell with small random offset
|
||||
$x = 10 + ($gridX + 0.5) * $cellWidth + mt_rand(-2, 2);
|
||||
$y = 20 + ($gridY + 0.5) * $cellHeight + mt_rand(-2, 2);
|
||||
|
||||
// Draw the dot with a random distraction color
|
||||
$color = $distractionColors[array_rand($distractionColors)];
|
||||
imagefilledellipse($image, $x, $y, $dotSize, $dotSize, $color);
|
||||
}
|
||||
|
||||
// Add subtle grid lines to help with counting
|
||||
$lightGray = imagecolorallocate($image, 230, 230, 230);
|
||||
for ($i = 1; $i < $gridCols; $i++) {
|
||||
imageline($image, 10 + $i * $cellWidth, 20, 10 + $i * $cellWidth, $height - 5, $lightGray);
|
||||
}
|
||||
for ($i = 1; $i < $gridRows; $i++) {
|
||||
imageline($image, 10, 20 + $i * $cellHeight, $width - 10, 20 + $i * $cellHeight, $lightGray);
|
||||
}
|
||||
|
||||
// Add minimal noise
|
||||
$this->addImageNoise($image, 15);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create image for position captcha
|
||||
*/
|
||||
protected function createPositionImage($image, $parts, $config)
|
||||
{
|
||||
$symbol = $parts[1];
|
||||
$position = $parts[2];
|
||||
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
// Allocate colors
|
||||
$black = imagecolorallocate($image, 0, 0, 0);
|
||||
$red = imagecolorallocate($image, 255, 0, 0);
|
||||
|
||||
// Draw instruction text
|
||||
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
|
||||
imagettftext($image, 9, 0, 5, 15, $black, $fontPath, "Position of symbol?");
|
||||
|
||||
// Determine symbol position based on the target position
|
||||
$symbolX = $width / 2;
|
||||
$symbolY = $height / 2;
|
||||
|
||||
switch ($position) {
|
||||
case 'top':
|
||||
$symbolX = $width / 2;
|
||||
$symbolY = 20;
|
||||
break;
|
||||
case 'bottom':
|
||||
$symbolX = $width / 2;
|
||||
$symbolY = $height - 10;
|
||||
break;
|
||||
case 'left':
|
||||
$symbolX = 20;
|
||||
$symbolY = $height / 2;
|
||||
break;
|
||||
case 'right':
|
||||
$symbolX = $width - 20;
|
||||
$symbolY = $height / 2;
|
||||
break;
|
||||
case 'center':
|
||||
$symbolX = $width / 2;
|
||||
$symbolY = $height / 2;
|
||||
break;
|
||||
}
|
||||
|
||||
// Draw the symbol - make it larger and in red for visibility
|
||||
imagettftext($image, 20, 0, $symbolX - 8, $symbolY + 8, $red, $fontPath, $symbol);
|
||||
|
||||
// Draw a grid to make positions clearer
|
||||
$gray = imagecolorallocate($image, 200, 200, 200);
|
||||
imageline($image, $width / 2, 15, $width / 2, $height - 5, $gray);
|
||||
imageline($image, 5, $height / 2, $width - 5, $height / 2, $gray);
|
||||
|
||||
// Add minimal noise
|
||||
$this->addImageNoise($image, 10);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create image for math captcha
|
||||
*/
|
||||
protected function createMathImage($image, $mathExpression, $config)
|
||||
{
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
// Get font and colors
|
||||
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
|
||||
$textColor = imagecolorallocate($image, 0, 0, 0);
|
||||
|
||||
// Draw the math expression
|
||||
$fontSize = 16;
|
||||
$textBox = imagettfbbox($fontSize, 0, $fontPath, $mathExpression);
|
||||
$textWidth = $textBox[2] - $textBox[0];
|
||||
$textHeight = $textBox[1] - $textBox[7];
|
||||
$textX = ($width - $textWidth) / 2;
|
||||
$textY = ($height + $textHeight) / 2;
|
||||
|
||||
imagettftext($image, $fontSize, 0, $textX, $textY, $textColor, $fontPath, $mathExpression);
|
||||
|
||||
// Add visual noise and distortions to prevent OCR
|
||||
$this->addImageNoise($image, 25);
|
||||
$this->addWaveDistortion($image);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create image for character captcha
|
||||
*/
|
||||
protected function createCharacterImage($image, $captcha_code, $config)
|
||||
{
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
// Get font settings with support for custom box dimensions, position, and colors
|
||||
$fontPath = __DIR__.'/../../fonts/'.($config['chars']['font'] ?? 'zxx-xed.ttf');
|
||||
$fontSize = $config['chars']['size'] ?? 16;
|
||||
|
||||
// Support custom text color (defaults to black)
|
||||
$textColorHex = $config['chars']['text'] ?? '#000000';
|
||||
$textRgb = $this->hexToRgb($textColorHex);
|
||||
$textColor = imagecolorallocate($image, $textRgb[0], $textRgb[1], $textRgb[2]);
|
||||
|
||||
// Support custom start position (useful for fine-tuning text placement)
|
||||
$startX = $config['chars']['start_x'] ?? ($width / (strlen($captcha_code) + 2));
|
||||
$baseY = $config['chars']['start_y'] ?? ($height / 2 + 5);
|
||||
|
||||
// Draw each character with random rotation and position
|
||||
$charWidth = $width / (strlen($captcha_code) + 2);
|
||||
|
||||
for ($i = 0; $i < strlen($captcha_code); $i++) {
|
||||
$char = $captcha_code[$i];
|
||||
$angle = mt_rand(-15, 15); // Random rotation
|
||||
|
||||
// Random vertical position with custom base Y
|
||||
$y = $baseY + mt_rand(-5, 5);
|
||||
|
||||
imagettftext($image, $fontSize, $angle, $startX, $y, $textColor, $fontPath, $char);
|
||||
|
||||
// Move to next character position with some randomness
|
||||
$startX += $charWidth + mt_rand(-5, 5);
|
||||
}
|
||||
|
||||
// Add visual noise and distortions
|
||||
$this->addImageNoise($image, 25);
|
||||
$this->addWaveDistortion($image);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add random noise to the image
|
||||
*/
|
||||
protected function addImageNoise($image, $density = 100)
|
||||
{
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
// For performance, reduce density
|
||||
$density = min($density, 30);
|
||||
|
||||
// Add random dots
|
||||
for ($i = 0; $i < $density; $i++) {
|
||||
$x = mt_rand(0, $width - 1);
|
||||
$y = mt_rand(0, $height - 1);
|
||||
$shade = mt_rand(150, 200);
|
||||
$color = imagecolorallocate($image, $shade, $shade, $shade);
|
||||
imagesetpixel($image, $x, $y, $color);
|
||||
}
|
||||
|
||||
// Add a few random lines
|
||||
$lineCount = min(3, mt_rand(2, 3));
|
||||
for ($i = 0; $i < $lineCount; $i++) {
|
||||
$x1 = mt_rand(0, $width / 4);
|
||||
$y1 = mt_rand(0, $height - 1);
|
||||
$x2 = mt_rand(3 * $width / 4, $width - 1);
|
||||
$y2 = mt_rand(0, $height - 1);
|
||||
$shade = mt_rand(150, 200);
|
||||
$color = imagecolorallocate($image, $shade, $shade, $shade);
|
||||
imageline($image, $x1, $y1, $x2, $y2, $color);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add wave distortion to the image
|
||||
*/
|
||||
protected function addWaveDistortion($image)
|
||||
{
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
|
||||
// Create temporary image
|
||||
$temp = imagecreatetruecolor($width, $height);
|
||||
$bg = imagecolorallocate($temp, 255, 255, 255);
|
||||
imagefill($temp, 0, 0, $bg);
|
||||
|
||||
// Copy original to temp
|
||||
imagecopy($temp, $image, 0, 0, 0, 0, $width, $height);
|
||||
|
||||
// Clear original image
|
||||
$bg = imagecolorallocate($image, 255, 255, 255);
|
||||
imagefill($image, 0, 0, $bg);
|
||||
|
||||
// Apply simplified wave distortion
|
||||
$amplitude = mt_rand(1, 2);
|
||||
$period = mt_rand(10, 15);
|
||||
|
||||
// Process only every 2nd pixel for better performance
|
||||
for ($x = 0; $x < $width; $x += 2) {
|
||||
$wave = sin($x / $period) * $amplitude;
|
||||
|
||||
for ($y = 0; $y < $height; $y += 2) {
|
||||
$yp = $y + $wave;
|
||||
|
||||
if ($yp >= 0 && $yp < $height) {
|
||||
$color = imagecolorat($temp, $x, $yp);
|
||||
imagesetpixel($image, $x, $y, $color);
|
||||
|
||||
// Fill adjacent pixel for better performance
|
||||
if ($x + 1 < $width && $y + 1 < $height) {
|
||||
imagesetpixel($image, $x + 1, $y, $color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
imagedestroy($temp);
|
||||
}
|
||||
|
||||
public function renderCaptchaImage($imageData): void
|
||||
{
|
||||
header("Content-type: image/jpeg");
|
||||
imagejpeg($imageData);
|
||||
}
|
||||
|
||||
public function validateCaptcha($formData): bool
|
||||
{
|
||||
$isValid = false;
|
||||
$capchaSessionData = $this->getSession();
|
||||
|
||||
// Make validation case-insensitive
|
||||
if (strtolower((string) $capchaSessionData) == strtolower((string) $formData)) {
|
||||
$isValid = true;
|
||||
}
|
||||
|
||||
// Debug validation if enabled
|
||||
$grav = Grav::instance();
|
||||
if ($grav['config']->get('plugins.form.basic_captcha.debug', false)) {
|
||||
$grav['log']->debug("Captcha Validation - Expected: '{$capchaSessionData}', Got: '{$formData}', Result: ".
|
||||
($isValid ? 'valid' : 'invalid'));
|
||||
}
|
||||
|
||||
// Regenerate a new captcha after validation
|
||||
$this->setSession($this->key, null);
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
private function hexToRgb($hex): array
|
||||
{
|
||||
return sscanf($hex, "#%02x%02x%02x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
|
||||
/**
|
||||
* Basic Captcha provider implementation
|
||||
*/
|
||||
class BasicCaptchaProvider implements CaptchaProviderInterface
|
||||
{
|
||||
/** @var array */
|
||||
protected $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = Grav::instance()['config']->get('plugins.form.basic_captcha', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate(array $form, array $params = []): array
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$session = $grav['session'];
|
||||
|
||||
try {
|
||||
// Get the expected answer from session
|
||||
// Make sure to use the same session key that the image generation code uses
|
||||
$expectedValue = $session->basic_captcha_value ?? null; // Changed from basic_captcha to basic_captcha_value
|
||||
|
||||
// Get the captcha type from session (stored during generation)
|
||||
$captchaType = $session->basic_captcha_type ?? null;
|
||||
|
||||
// Get the user's answer
|
||||
$userValue = $form['basic-captcha'] ?? null;
|
||||
|
||||
if (!$expectedValue) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'missing-session-data',
|
||||
'details' => ['error' => 'No captcha value found in session']
|
||||
];
|
||||
}
|
||||
|
||||
if (!$userValue) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'missing-input-response',
|
||||
'details' => ['error' => 'User did not enter a captcha value']
|
||||
];
|
||||
}
|
||||
|
||||
// Compare the values based on the type stored in session
|
||||
// If type is not in session, try to infer from global/field config
|
||||
if (!$captchaType) {
|
||||
$captchaType = $this->config['captcha_type'] ?? $this->config['type'] ?? 'characters';
|
||||
}
|
||||
|
||||
if ($captchaType === 'characters') {
|
||||
$isValid = strtolower((string)$userValue) === strtolower((string)$expectedValue);
|
||||
} else {
|
||||
// For math, dotcount, position - ensure both are treated as integers or exact match
|
||||
$isValid = (int)$userValue === (int)$expectedValue;
|
||||
}
|
||||
|
||||
if (!$isValid) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'validation-failed',
|
||||
'details' => [
|
||||
'expected' => $expectedValue,
|
||||
'received' => $userValue
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Clear the session values to prevent reuse
|
||||
$session->basic_captcha_value = null;
|
||||
$session->basic_captcha_type = null;
|
||||
|
||||
return [
|
||||
'success' => true
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'details' => ['exception' => get_class($e)]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getClientProperties(string $formId, array $field): array
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$session = $grav['session'];
|
||||
|
||||
// Merge field-level configuration with global defaults
|
||||
$fieldConfig = array_replace_recursive($this->config, $field);
|
||||
|
||||
// Remove non-config keys from field array
|
||||
unset($fieldConfig['type'], $fieldConfig['label'], $fieldConfig['placeholder'],
|
||||
$fieldConfig['validate'], $fieldConfig['name'], $fieldConfig['classes']);
|
||||
|
||||
// Generate unique field ID for this form/field combination
|
||||
$fieldId = md5($formId . '_basic_captcha_' . ($field['name'] ?? 'default'));
|
||||
|
||||
// Store field configuration in session for image generation
|
||||
$session->{"basic_captcha_config_{$fieldId}"} = $fieldConfig;
|
||||
|
||||
$captchaType = $fieldConfig['type'] ?? 'math';
|
||||
|
||||
return [
|
||||
'provider' => 'basic-captcha',
|
||||
'type' => $captchaType,
|
||||
'imageUrl' => "/forms-basic-captcha-image.jpg?field={$fieldId}",
|
||||
'refreshable' => true,
|
||||
'containerId' => "basic-captcha-{$formId}",
|
||||
'fieldId' => $fieldId
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'forms/fields/basic-captcha/basic-captcha.html.twig';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
|
||||
/**
|
||||
* Factory for captcha providers
|
||||
*/
|
||||
class CaptchaFactory
|
||||
{
|
||||
/** @var array */
|
||||
protected static $providers = [];
|
||||
|
||||
/**
|
||||
* Register a captcha provider
|
||||
*
|
||||
* @param string $name Provider name
|
||||
* @param string|CaptchaProviderInterface $provider Provider class or instance
|
||||
* @return void
|
||||
*/
|
||||
public static function registerProvider(string $name, $provider): void
|
||||
{
|
||||
// If it's a class name, instantiate it
|
||||
if (is_string($provider) && class_exists($provider)) {
|
||||
$provider = new $provider();
|
||||
}
|
||||
|
||||
if (!$provider instanceof CaptchaProviderInterface) {
|
||||
Grav::instance()['log']->error("Cannot register captcha provider '{$name}': Provider must implement CaptchaProviderInterface");
|
||||
return;
|
||||
}
|
||||
|
||||
self::$providers[$name] = $provider;
|
||||
// Grav::instance()['log']->debug("Registered captcha provider: {$name}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is registered
|
||||
*
|
||||
* @param string $name Provider name
|
||||
* @return bool
|
||||
*/
|
||||
public static function hasProvider(string $name): bool
|
||||
{
|
||||
return isset(self::$providers[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider by name
|
||||
*
|
||||
* @param string $name Provider name
|
||||
* @return CaptchaProviderInterface|null Provider instance or null if not found
|
||||
*/
|
||||
public static function getProvider(string $name): ?CaptchaProviderInterface
|
||||
{
|
||||
return self::$providers[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered providers
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getProviders(): array
|
||||
{
|
||||
return self::$providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all default captcha providers
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function registerDefaultProviders(): void
|
||||
{
|
||||
// Register built-in providers
|
||||
self::registerProvider('recaptcha', new ReCaptchaProvider());
|
||||
self::registerProvider('turnstile', new TurnstileProvider());
|
||||
self::registerProvider('basic-captcha', new BasicCaptchaProvider());
|
||||
|
||||
// Log the registration
|
||||
// Grav::instance()['log']->debug('Registered default captcha providers');
|
||||
}
|
||||
}
|
||||
244
config/www/user/plugins/form/classes/Captcha/CaptchaManager.php
Normal file
244
config/www/user/plugins/form/classes/Captcha/CaptchaManager.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Plugin\Form\Form;
|
||||
use RocketTheme\Toolbox\Event\Event;
|
||||
|
||||
/**
|
||||
* Central manager for captcha processing
|
||||
*/
|
||||
class CaptchaManager
|
||||
{
|
||||
/**
|
||||
* Initialize the captcha manager
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function initialize(): void
|
||||
{
|
||||
// Register all default captcha providers
|
||||
CaptchaFactory::registerDefaultProviders();
|
||||
|
||||
// Allow plugins to register custom captcha providers
|
||||
Grav::instance()->fireEvent('onFormRegisterCaptchaProviders');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a captcha validation
|
||||
*
|
||||
* @param Form $form The form to validate
|
||||
* @param array|null $params Optional parameters
|
||||
* @return bool True if validation succeeded
|
||||
*/
|
||||
public static function validateCaptcha(Form $form, $params = null): bool
|
||||
{
|
||||
// Handle case where $params is a boolean (backward compatibility)
|
||||
if (!is_array($params)) {
|
||||
$params = [];
|
||||
}
|
||||
|
||||
// --- 1. Find the captcha field in the form ---
|
||||
$captchaField = null;
|
||||
$providerName = null;
|
||||
|
||||
$formFields = $form->value()->blueprints()->get('form/fields');
|
||||
foreach ($formFields as $fieldName => $fieldDef) {
|
||||
$fieldType = $fieldDef['type'] ?? null;
|
||||
|
||||
// Check for modern captcha type with provider
|
||||
if ($fieldType === 'captcha') {
|
||||
$captchaField = $fieldDef;
|
||||
$providerName = $fieldDef['provider'] ?? 'recaptcha';
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for legacy type-based providers (like basic-captcha and turnstile)
|
||||
// This is for backward compatibility
|
||||
elseif ($fieldType && CaptchaFactory::hasProvider($fieldType)) {
|
||||
$captchaField = $fieldDef;
|
||||
$providerName = $fieldType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$captchaField || !$providerName) {
|
||||
// No captcha field found or no provider specified
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 2. Get provider and validate ---
|
||||
$provider = CaptchaFactory::getProvider($providerName);
|
||||
if (!$provider) {
|
||||
Grav::instance()['log']->error("Form Captcha: Unknown provider '{$providerName}' requested");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow plugins to modify the validation parameters
|
||||
$validationEvent = new Event([
|
||||
'form' => $form,
|
||||
'field' => $captchaField,
|
||||
'provider' => $providerName,
|
||||
'params' => $params
|
||||
]);
|
||||
Grav::instance()->fireEvent('onBeforeCaptchaValidation', $validationEvent);
|
||||
$params = $validationEvent['params'];
|
||||
|
||||
// Validate using the provider
|
||||
try {
|
||||
$result = $provider->validate($form->value()->toArray(), $params);
|
||||
|
||||
if (!$result['success']) {
|
||||
$logDetails = $result['details'] ?? [];
|
||||
$errorMessage = self::getErrorMessage($captchaField, $result['error'] ?? 'validation-failed', $providerName);
|
||||
|
||||
// Fire validation error event
|
||||
Grav::instance()->fireEvent('onFormValidationError', new Event([
|
||||
'form' => $form,
|
||||
'message' => $errorMessage,
|
||||
'provider' => $providerName
|
||||
]));
|
||||
|
||||
// Log the failure
|
||||
$uri = Grav::instance()['uri'];
|
||||
Grav::instance()['log']->warning(
|
||||
"Form Captcha ({$providerName}) validation failed: [{$uri->route()}] Details: " .
|
||||
json_encode($logDetails)
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Log success
|
||||
Grav::instance()['log']->info("Form Captcha ({$providerName}) validation successful for form: " . $form->name);
|
||||
|
||||
// Fire success event
|
||||
Grav::instance()->fireEvent('onCaptchaValidationSuccess', new Event([
|
||||
'form' => $form,
|
||||
'provider' => $providerName
|
||||
]));
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
// Handle other errors
|
||||
Grav::instance()['log']->error("Form Captcha ({$providerName}) validation error: " . $e->getMessage());
|
||||
|
||||
$errorMessage = Grav::instance()['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
|
||||
Grav::instance()->fireEvent('onFormValidationError', new Event([
|
||||
'form' => $form,
|
||||
'message' => $errorMessage,
|
||||
'provider' => $providerName,
|
||||
'exception' => $e
|
||||
]));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate error message based on error code and field definition
|
||||
*
|
||||
* @param array $field Field definition
|
||||
* @param string $errorCode Error code
|
||||
* @param string $provider Provider name
|
||||
* @return string
|
||||
*/
|
||||
protected static function getErrorMessage(array $field, string $errorCode, string $provider): string
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
|
||||
// First check for specific message in field definition
|
||||
if (isset($field['captcha_not_validated'])) {
|
||||
return $field['captcha_not_validated'];
|
||||
}
|
||||
|
||||
// Then check for specific error code message
|
||||
if ($errorCode === 'missing-input-response') {
|
||||
return $grav['language']->translate('PLUGIN_FORM.ERROR_CAPTCHA_NOT_COMPLETED');
|
||||
}
|
||||
|
||||
// Allow providers to supply custom error messages via event
|
||||
$messageEvent = new Event([
|
||||
'provider' => $provider,
|
||||
'errorCode' => $errorCode,
|
||||
'field' => $field,
|
||||
'message' => null
|
||||
]);
|
||||
$grav->fireEvent('onCaptchaErrorMessage', $messageEvent);
|
||||
|
||||
if ($messageEvent['message']) {
|
||||
return $messageEvent['message'];
|
||||
}
|
||||
|
||||
// Finally fall back to generic message
|
||||
return $grav['language']->translate('PLUGIN_FORM.ERROR_VALIDATING_CAPTCHA');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client-side initialization data for a captcha field
|
||||
*
|
||||
* @param string $formId Form ID
|
||||
* @param array $field Field definition
|
||||
* @return array Client properties
|
||||
*/
|
||||
public static function getClientProperties(string $formId, array $field): array
|
||||
{
|
||||
$providerName = $field['provider'] ?? null;
|
||||
|
||||
// Handle legacy field types as providers
|
||||
if (!$providerName && isset($field['type'])) {
|
||||
$fieldType = $field['type'];
|
||||
if (CaptchaFactory::hasProvider($fieldType)) {
|
||||
$providerName = $fieldType;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$providerName) {
|
||||
// Default to recaptcha for backward compatibility
|
||||
$providerName = 'recaptcha';
|
||||
}
|
||||
|
||||
$provider = CaptchaFactory::getProvider($providerName);
|
||||
|
||||
if (!$provider) {
|
||||
return [
|
||||
'provider' => $providerName,
|
||||
'error' => "Unknown captcha provider: {$providerName}"
|
||||
];
|
||||
}
|
||||
|
||||
return $provider->getClientProperties($formId, $field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template name for a captcha field
|
||||
*
|
||||
* @param array $field Field definition
|
||||
* @return string Template name
|
||||
*/
|
||||
public static function getTemplateName(array $field): string
|
||||
{
|
||||
$providerName = $field['provider'] ?? null;
|
||||
|
||||
// Handle legacy field types as providers
|
||||
if (!$providerName && isset($field['type'])) {
|
||||
$fieldType = $field['type'];
|
||||
if (CaptchaFactory::hasProvider($fieldType)) {
|
||||
$providerName = $fieldType;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$providerName) {
|
||||
// Default to recaptcha for backward compatibility
|
||||
$providerName = 'recaptcha';
|
||||
}
|
||||
|
||||
$provider = CaptchaFactory::getProvider($providerName);
|
||||
|
||||
if (!$provider) {
|
||||
return 'forms/fields/captcha/default.html.twig';
|
||||
}
|
||||
|
||||
return $provider->getTemplateName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
/**
|
||||
* Interface for captcha providers
|
||||
*/
|
||||
interface CaptchaProviderInterface
|
||||
{
|
||||
/**
|
||||
* Validate a captcha response
|
||||
*
|
||||
* @param array $form Form data array
|
||||
* @param array $params Optional parameters
|
||||
* @return array Validation result with 'success' key and optional 'error' and 'details' keys
|
||||
*/
|
||||
public function validate(array $form, array $params = []): array;
|
||||
|
||||
/**
|
||||
* Get client-side properties for the captcha
|
||||
*
|
||||
* @param string $formId Form ID
|
||||
* @param array $field Field definition
|
||||
* @return array Client properties
|
||||
*/
|
||||
public function getClientProperties(string $formId, array $field): array;
|
||||
|
||||
/**
|
||||
* Get the template name for the captcha field
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTemplateName(): string;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Uri;
|
||||
|
||||
/**
|
||||
* Google reCAPTCHA provider implementation
|
||||
*/
|
||||
class ReCaptchaProvider implements CaptchaProviderInterface
|
||||
{
|
||||
/** @var array */
|
||||
protected $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = Grav::instance()['config']->get('plugins.form.recaptcha', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate(array $form, array $params = []): array
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$uri = $grav['uri'];
|
||||
$ip = Uri::ip();
|
||||
$hostname = $uri->host();
|
||||
|
||||
try {
|
||||
$secretKey = $params['recaptcha_secret'] ?? $params['recatpcha_secret'] ??
|
||||
$this->config['secret_key'] ?? null;
|
||||
|
||||
$defaultVersion = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
|
||||
$version = $this->normalizeVersion($params['recaptcha_version'] ?? $defaultVersion);
|
||||
|
||||
$payloadVersion = $this->detectVersionFromPayload($form);
|
||||
if ($payloadVersion !== null) {
|
||||
$version = $payloadVersion;
|
||||
}
|
||||
|
||||
if (!$secretKey) {
|
||||
throw new \RuntimeException("reCAPTCHA secret key not configured.");
|
||||
}
|
||||
|
||||
$requestMethod = extension_loaded('curl') ? new \ReCaptcha\RequestMethod\CurlPost() : null;
|
||||
$recaptcha = new \ReCaptcha\ReCaptcha($secretKey, $requestMethod);
|
||||
|
||||
// Handle V3
|
||||
if ($version === '3') {
|
||||
// For V3, look for token in both top level and data[] structure
|
||||
$token = $form['token'] ?? ($form['data']['token'] ?? null);
|
||||
$action = $form['action'] ?? ($form['data']['action'] ?? null);
|
||||
|
||||
if (!$token) {
|
||||
$grav['log']->debug('reCAPTCHA validation failed: token missing for v3');
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'missing-input-response',
|
||||
'details' => ['error' => 'missing-input-response', 'version' => 'v3']
|
||||
];
|
||||
}
|
||||
|
||||
$recaptcha->setExpectedHostname($hostname);
|
||||
|
||||
// Set action if provided
|
||||
if ($action) {
|
||||
$recaptcha->setExpectedAction($action);
|
||||
}
|
||||
|
||||
// Set score threshold
|
||||
$recaptcha->setScoreThreshold($this->config['score_threshold'] ?? 0.5);
|
||||
}
|
||||
// Handle V2 (both checkbox and invisible)
|
||||
else {
|
||||
// For V2, look for standard response parameter
|
||||
$token = $form['g-recaptcha-response'] ?? ($form['data']['g-recaptcha-response'] ?? null);
|
||||
if (!$token) {
|
||||
$post = $grav['uri']->post();
|
||||
if (is_array($post)) {
|
||||
if (isset($post['g-recaptcha-response'])) {
|
||||
$token = $post['g-recaptcha-response'];
|
||||
} elseif (isset($post['g_recaptcha_response'])) {
|
||||
$token = $post['g_recaptcha_response'];
|
||||
} elseif (isset($post['data']) && is_array($post['data'])) {
|
||||
if (isset($post['data']['g-recaptcha-response'])) {
|
||||
$token = $post['data']['g-recaptcha-response'];
|
||||
} elseif (isset($post['data']['g_recaptcha_response'])) {
|
||||
$token = $post['data']['g_recaptcha_response'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$token) {
|
||||
$grav['log']->debug('reCAPTCHA validation failed: g-recaptcha-response missing for v2');
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'missing-input-response',
|
||||
'details' => ['error' => 'missing-input-response', 'version' => 'v2']
|
||||
];
|
||||
}
|
||||
|
||||
$recaptcha->setExpectedHostname($hostname);
|
||||
}
|
||||
|
||||
// Log validation attempt
|
||||
$grav['log']->debug('reCAPTCHA validation attempt for version ' . $version);
|
||||
|
||||
$validationResponseObject = $recaptcha->verify($token, $ip);
|
||||
$isValid = $validationResponseObject->isSuccess();
|
||||
|
||||
if (!$isValid) {
|
||||
$errorCodes = $validationResponseObject->getErrorCodes();
|
||||
$grav['log']->debug('reCAPTCHA validation failed: ' . json_encode($errorCodes));
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'validation-failed',
|
||||
'details' => ['error-codes' => $errorCodes, 'version' => $version]
|
||||
];
|
||||
}
|
||||
|
||||
// For V3, check if score is available and log it (helpful for debugging/tuning)
|
||||
if ($version === '3' && method_exists($validationResponseObject, 'getScore')) {
|
||||
$score = $validationResponseObject->getScore();
|
||||
$grav['log']->debug('reCAPTCHA v3 validation successful with score: ' . $score);
|
||||
} else {
|
||||
$grav['log']->debug('reCAPTCHA validation successful');
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$grav['log']->error('reCAPTCHA validation error: ' . $e->getMessage());
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'details' => ['exception' => get_class($e)]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize version values to the internal format we use elsewhere.
|
||||
*/
|
||||
protected function normalizeVersion($version): string
|
||||
{
|
||||
if ($version === null || $version === '') {
|
||||
return '2-checkbox';
|
||||
}
|
||||
|
||||
if ($version === 3 || $version === '3') {
|
||||
return '3';
|
||||
}
|
||||
|
||||
if ($version === 2 || $version === '2') {
|
||||
return '2-checkbox';
|
||||
}
|
||||
|
||||
return (string) $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the recaptcha version from the submitted payload when possible.
|
||||
*/
|
||||
protected function detectVersionFromPayload(array $form): ?string
|
||||
{
|
||||
$formData = isset($form['data']) && is_array($form['data']) ? $form['data'] : [];
|
||||
|
||||
$grav = Grav::instance();
|
||||
$config = $grav['config'];
|
||||
if ($config->get('plugins.form.debug')) {
|
||||
try {
|
||||
$grav['log']->debug('reCAPTCHA payload inspection', [
|
||||
'top_keys' => array_keys($form),
|
||||
'data_keys' => array_keys($formData),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore logging issues, detection should continue.
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('token', $form) || array_key_exists('token', $formData)) {
|
||||
return '3';
|
||||
}
|
||||
|
||||
if (array_key_exists('g-recaptcha-response', $form) || array_key_exists('g-recaptcha-response', $formData)) {
|
||||
return '2-checkbox';
|
||||
}
|
||||
|
||||
if (array_key_exists('g_recaptcha_response', $form) || array_key_exists('g_recaptcha_response', $formData)) {
|
||||
// Support alternative key naming just in case
|
||||
return '2-checkbox';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getClientProperties(string $formId, array $field): array
|
||||
{
|
||||
$siteKey = $field['recaptcha_site_key'] ?? $this->config['site_key'] ?? null;
|
||||
$theme = $field['recaptcha_theme'] ?? $this->config['theme'] ?? 'light';
|
||||
$version = $this->normalizeVersion($field['recaptcha_version'] ?? $this->config['version'] ?? '2-checkbox');
|
||||
|
||||
// Determine which version we're using
|
||||
$isV3 = $version === '3';
|
||||
$isInvisible = $version === '2-invisible';
|
||||
|
||||
// Log the configuration to help with debugging
|
||||
$grav = Grav::instance();
|
||||
$grav['log']->debug("reCAPTCHA config for form {$formId}: version={$version}, siteKey=" .
|
||||
(empty($siteKey) ? 'MISSING' : 'configured'));
|
||||
|
||||
return [
|
||||
'provider' => 'recaptcha',
|
||||
'siteKey' => $siteKey,
|
||||
'theme' => $theme,
|
||||
'version' => $version,
|
||||
'isV3' => $isV3,
|
||||
'isInvisible' => $isInvisible,
|
||||
'containerId' => "g-recaptcha-{$formId}",
|
||||
'scriptUrl' => "https://www.google.com/recaptcha/api.js" . ($isV3 ? '?render=' . $siteKey : ''),
|
||||
'initFunctionName' => "initRecaptcha_{$formId}"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
// Different templates based on version
|
||||
$version = $this->normalizeVersion($this->config['version'] ?? '2-checkbox');
|
||||
|
||||
$isV3 = $version === '3';
|
||||
$isInvisible = $version === '2-invisible';
|
||||
|
||||
if ($isV3) {
|
||||
return 'forms/fields/recaptcha/recaptchav3.html.twig';
|
||||
} elseif ($isInvisible) {
|
||||
return 'forms/fields/recaptcha/recaptcha-invisible.html.twig';
|
||||
}
|
||||
|
||||
return 'forms/fields/recaptcha/recaptcha.html.twig';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
namespace Grav\Plugin\Form\Captcha;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Uri;
|
||||
use Grav\Common\HTTP\Client;
|
||||
|
||||
/**
|
||||
* Cloudflare Turnstile provider implementation
|
||||
*/
|
||||
class TurnstileProvider implements CaptchaProviderInterface
|
||||
{
|
||||
/** @var array */
|
||||
protected $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = Grav::instance()['config']->get('plugins.form.turnstile', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate(array $form, array $params = []): array
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$uri = $grav['uri'];
|
||||
$ip = Uri::ip();
|
||||
|
||||
$grav['log']->debug('Turnstile validation - entire form data: ' . json_encode(array_keys($form)));
|
||||
|
||||
try {
|
||||
$secretKey = $params['turnstile_secret'] ??
|
||||
$this->config['secret_key'] ?? null;
|
||||
|
||||
if (!$secretKey) {
|
||||
$grav['log']->error("Turnstile secret key not configured.");
|
||||
throw new \RuntimeException("Turnstile secret key not configured.");
|
||||
}
|
||||
|
||||
// First check $_POST directly, then fallback to form data
|
||||
$token = $_POST['cf-turnstile-response'] ?? null;
|
||||
if (!$token) {
|
||||
$token = $form['cf-turnstile-response'] ?? null;
|
||||
}
|
||||
|
||||
// Log raw POST data for debugging
|
||||
$grav['log']->debug('Turnstile validation - raw POST data keys: ' . json_encode(array_keys($_POST)));
|
||||
$grav['log']->debug('Turnstile validation - token present: ' . ($token ? 'YES' : 'NO'));
|
||||
|
||||
if ($token) {
|
||||
$grav['log']->debug('Turnstile token length: ' . strlen($token));
|
||||
}
|
||||
|
||||
if (!$token) {
|
||||
$grav['log']->warning('Turnstile validation failed: missing token response');
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'missing-input-response',
|
||||
'details' => ['error' => 'missing-input-response']
|
||||
];
|
||||
}
|
||||
|
||||
$client = \Grav\Common\HTTP\Client::getClient();
|
||||
$grav['log']->debug('Turnstile validation - calling API with token');
|
||||
|
||||
$response = $client->request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
|
||||
'body' => [
|
||||
'secret' => $secretKey,
|
||||
'response' => $token,
|
||||
'remoteip' => $ip
|
||||
]
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$grav['log']->debug('Turnstile API response status: ' . $statusCode);
|
||||
|
||||
$content = $response->toArray();
|
||||
$grav['log']->debug('Turnstile API response: ' . json_encode($content));
|
||||
|
||||
if (!isset($content['success'])) {
|
||||
$grav['log']->error("Invalid response from Turnstile verification (missing 'success' key).");
|
||||
throw new \RuntimeException("Invalid response from Turnstile verification (missing 'success' key).");
|
||||
}
|
||||
|
||||
if (!$content['success']) {
|
||||
$grav['log']->warning('Turnstile validation failed: ' . json_encode($content));
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'validation-failed',
|
||||
'details' => ['error-codes' => $content['error-codes'] ?? ['validation-failed']]
|
||||
];
|
||||
}
|
||||
|
||||
$grav['log']->debug('Turnstile validation successful');
|
||||
return [
|
||||
'success' => true
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$grav['log']->error("Turnstile validation error: " . $e->getMessage());
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'details' => ['exception' => get_class($e)]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getClientProperties(string $formId, array $field): array
|
||||
{
|
||||
$siteKey = $field['turnstile_site_key'] ?? $this->config['site_key'] ?? null;
|
||||
$theme = $field['turnstile_theme'] ?? $this->config['theme'] ?? 'auto';
|
||||
|
||||
return [
|
||||
'provider' => 'turnstile',
|
||||
'siteKey' => $siteKey,
|
||||
'theme' => $theme,
|
||||
'containerId' => "cf-turnstile-{$formId}",
|
||||
'scriptUrl' => "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
'initFunctionName' => "initTurnstile_{$formId}"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return 'forms/fields/turnstile/turnstile.html.twig';
|
||||
}
|
||||
}
|
||||
1462
config/www/user/plugins/form/classes/Form.php
Normal file
1462
config/www/user/plugins/form/classes/Form.php
Normal file
File diff suppressed because it is too large
Load Diff
38
config/www/user/plugins/form/classes/FormFactory.php
Normal file
38
config/www/user/plugins/form/classes/FormFactory.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Form;
|
||||
|
||||
use Grav\Common\Page\Interfaces\PageInterface;
|
||||
use Grav\Common\Page\Page;
|
||||
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
|
||||
use Grav\Framework\Form\Interfaces\FormInterface;
|
||||
|
||||
class FormFactory implements FormFactoryInterface
|
||||
{
|
||||
/**
|
||||
* Create form using the header of the page.
|
||||
*
|
||||
* @param Page $page
|
||||
* @param string $name
|
||||
* @param array $form
|
||||
* @return Form|null
|
||||
* @deprecated 1.6 Use FormFactory::createFormByPage() instead.
|
||||
*/
|
||||
public function createPageForm(Page $page, string $name, array $form): ?FormInterface
|
||||
{
|
||||
return new Form($page, $name, $form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create form using the header of the page.
|
||||
*
|
||||
* @param PageInterface $page
|
||||
* @param string $name
|
||||
* @param array $form
|
||||
* @return Form|null
|
||||
*/
|
||||
public function createFormForPage(PageInterface $page, string $name, array $form): ?FormInterface
|
||||
{
|
||||
return new Form($page, $name, $form);
|
||||
}
|
||||
}
|
||||
129
config/www/user/plugins/form/classes/Forms.php
Normal file
129
config/www/user/plugins/form/classes/Forms.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Grav\Plugin\Form;
|
||||
|
||||
use Grav\Common\Page\Interfaces\PageInterface;
|
||||
use Grav\Common\Page\Page;
|
||||
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
|
||||
use Grav\Framework\Form\Interfaces\FormInterface;
|
||||
|
||||
class Forms
|
||||
{
|
||||
/** @var array|FormFactoryInterface[] */
|
||||
private $types;
|
||||
/** @var FormInterface|null */
|
||||
private $form;
|
||||
|
||||
/**
|
||||
* Forms constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->registerType('form', new FormFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @param FormFactoryInterface $factory
|
||||
*/
|
||||
public function registerType(string $type, FormFactoryInterface $factory): void
|
||||
{
|
||||
$this->types[$type] = $factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
*/
|
||||
public function unregisterType($type): void
|
||||
{
|
||||
unset($this->types[$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @return bool
|
||||
*/
|
||||
public function hasType(string $type): bool
|
||||
{
|
||||
return isset($this->types[$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getTypes(): array
|
||||
{
|
||||
return array_keys($this->types);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PageInterface $page
|
||||
* @param string|null $name
|
||||
* @param array|null $form
|
||||
* @return FormInterface|null
|
||||
*/
|
||||
public function createPageForm(PageInterface $page, ?string $name = null, ?array $form = null): ?FormInterface
|
||||
{
|
||||
if (null === $form) {
|
||||
[$name, $form] = $this->getPageParameters($page, $name);
|
||||
}
|
||||
|
||||
if (null === $form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $form['type'] ?? 'form';
|
||||
$factory = $this->types[$type] ?? null;
|
||||
|
||||
if ($factory) {
|
||||
if (is_callable([$factory, 'createFormForPage'])) {
|
||||
return $factory->createFormForPage($page, $name, $form);
|
||||
}
|
||||
|
||||
if ($page instanceof Page) {
|
||||
// @phpstan-ignore-next-line
|
||||
return $factory->createPageForm($page, $name, $form);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface|null
|
||||
*/
|
||||
public function getActiveForm(): ?FormInterface
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface $form
|
||||
* @return void
|
||||
*/
|
||||
public function setActiveForm(FormInterface $form): void
|
||||
{
|
||||
$this->form = $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PageInterface $page
|
||||
* @param string|null $name
|
||||
* @return array
|
||||
*/
|
||||
protected function getPageParameters(PageInterface $page, ?string $name): array
|
||||
{
|
||||
$forms = $page->getForms();
|
||||
|
||||
if ($name) {
|
||||
// If form with given name was found, use that.
|
||||
$form = $forms[$name] ?? null;
|
||||
} else {
|
||||
// Otherwise pick up the first form.
|
||||
$form = reset($forms) ?: null;
|
||||
$name = (string)key($forms);
|
||||
}
|
||||
|
||||
return [$name, $form];
|
||||
}
|
||||
}
|
||||
163
config/www/user/plugins/form/classes/TwigExtension.php
Normal file
163
config/www/user/plugins/form/classes/TwigExtension.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php declare(strict_types=1);
|
||||
|
||||
namespace Grav\Plugin\Form;
|
||||
|
||||
use Grav\Framework\Form\Interfaces\FormInterface;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Class GravExtension
|
||||
* @package Grav\Common\Twig\Extension
|
||||
*/
|
||||
class TwigExtension extends AbstractExtension
|
||||
{
|
||||
public function getFilters()
|
||||
{
|
||||
return [
|
||||
new TwigFilter('value_and_label', [$this, 'valueAndLabel'])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of all functions.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('prepare_form_fields', [$this, 'prepareFormFields'], ['needs_context' => true]),
|
||||
new TwigFunction('prepare_form_field', [$this, 'prepareFormField'], ['needs_context' => true]),
|
||||
new TwigFunction('include_form_field', [$this, 'includeFormField']),
|
||||
];
|
||||
}
|
||||
|
||||
public function valueAndLabel($value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($value as $key => $label) {
|
||||
$list[] = ['value' => $key, 'label' => $label];
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters form fields for the current parent.
|
||||
*
|
||||
* @param array $context
|
||||
* @param array $fields Form fields
|
||||
* @param string|null $parent Parent field name if available
|
||||
* @return array
|
||||
*/
|
||||
public function prepareFormFields(array $context, $fields, $parent = null): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
if (is_iterable($fields)) {
|
||||
foreach ($fields as $name => $field) {
|
||||
$field = $this->prepareFormField($context, $field, $name, $parent);
|
||||
if ($field) {
|
||||
$list[$field['name']] = $field;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters field name by changing dot notation into array notation.
|
||||
*
|
||||
* @param array $context
|
||||
* @param array $field Form field
|
||||
* @param string|int|null $name Field name (defaults to field.name)
|
||||
* @param string|null $parent Parent field name if available
|
||||
* @param array|null $options List of options to override
|
||||
* @return array|null
|
||||
*/
|
||||
public function prepareFormField(array $context, $field, $name = null, $parent = null, array $options = []): ?array
|
||||
{
|
||||
// Make sure that the field is a valid form field type and is not being ignored.
|
||||
if (empty($field['type']) || ($field['validate']['ignore'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If field has already been prepared, we do not need to do anything.
|
||||
if (!empty($field['prepared'])) {
|
||||
return $field;
|
||||
}
|
||||
|
||||
// Check if we have just a list of fields (no name given).
|
||||
$fieldName = (string)($field['name'] ?? $name);
|
||||
if (!is_string($name) || $name === '') {
|
||||
// Look at the field.name and if not set, fall back to the key.
|
||||
$name = $fieldName;
|
||||
}
|
||||
|
||||
// Make sure that the field has a name.
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefix name with the parent name if needed.
|
||||
if (str_starts_with($name, '.')) {
|
||||
$plainName = (string)substr($name, 1);
|
||||
$field['plain_name'] = $plainName;
|
||||
$name = $parent ? $parent . $name : $plainName;
|
||||
} elseif (isset($options['key'])) {
|
||||
$name = str_replace('*', $options['key'], $name);
|
||||
}
|
||||
|
||||
unset($options['key']);
|
||||
|
||||
// Set fields as readonly if form is in readonly mode.
|
||||
/** @var FormInterface $form */
|
||||
$form = $context['form'] ?? null;
|
||||
if ($form && method_exists($form, 'isEnabled') && !$form->isEnabled()) {
|
||||
$options['disabled'] = true;
|
||||
}
|
||||
|
||||
// Loop through options
|
||||
foreach ($options as $key => $option) {
|
||||
$field[$key] = $option;
|
||||
}
|
||||
|
||||
// Always set field name.
|
||||
$field['name'] = $name;
|
||||
$field['prepared'] = true;
|
||||
|
||||
return $field;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @param string|string[]|null $layouts
|
||||
* @param string|null $default
|
||||
* @return string[]
|
||||
*/
|
||||
public function includeFormField(string $type, $layouts = null, ?string $default = null): array
|
||||
{
|
||||
$list = [];
|
||||
foreach ((array)$layouts as $layout) {
|
||||
$list[] = "forms/fields/{$type}/{$layout}-{$type}.html.twig";
|
||||
}
|
||||
$list[] = "forms/fields/{$type}/{$type}.html.twig";
|
||||
|
||||
if ($default) {
|
||||
foreach ((array)$layouts as $layout) {
|
||||
$list[] = "forms/fields/{$default}/{$layout}-{$default}.html.twig";
|
||||
}
|
||||
$list[] = "forms/fields/{$default}/{$default}.html.twig";
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user