chore: create production-v2 branch with content only

This branch contains ONLY:
- Pages (config/www/user/pages/)
- Themes (config/www/user/themes/)
- Plugins (config/www/user/plugins/)
- PRODUCTION.md
- Minimal .gitignore

Clean slate for production deployment.
All development files, configs, scripts removed.
This commit is contained in:
Charles N Wyble
2026-01-13 20:12:03 -05:00
commit 665c7f47af
2558 changed files with 434613 additions and 0 deletions

View 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");
}
}

View File

@@ -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';
}
}

View File

@@ -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');
}
}

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -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';
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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];
}
}

View 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;
}
}