diff --git a/apps/bootstrap/protected/controllers/SiteController.php b/apps/bootstrap/protected/controllers/SiteController.php index d1186f6..b06ed06 100644 --- a/apps/bootstrap/protected/controllers/SiteController.php +++ b/apps/bootstrap/protected/controllers/SiteController.php @@ -6,6 +6,15 @@ use app\models\ContactForm; class SiteController extends Controller { + public function actions() + { + return array( + 'captcha' => array( + 'class' => 'yii\web\CaptchaAction', + ), + ); + } + public function actionIndex() { echo $this->render('index'); diff --git a/apps/bootstrap/protected/models/ContactForm.php b/apps/bootstrap/protected/models/ContactForm.php index 5124b2c..7b713a1 100644 --- a/apps/bootstrap/protected/models/ContactForm.php +++ b/apps/bootstrap/protected/models/ContactForm.php @@ -26,7 +26,7 @@ class ContactForm extends Model // email has to be a valid email address array('email', 'email'), // verifyCode needs to be entered correctly - //array('verifyCode', 'captcha', 'allowEmpty' => !Captcha::checkRequirements()), + array('verifyCode', 'captcha'), ); } diff --git a/apps/bootstrap/protected/views/site/contact.php b/apps/bootstrap/protected/views/site/contact.php index 4115b53..bee1ede 100644 --- a/apps/bootstrap/protected/views/site/contact.php +++ b/apps/bootstrap/protected/views/site/contact.php @@ -1,6 +1,7 @@ params['breadcrumbs'][] = $this->title; field($model, 'email')->textInput(); ?> field($model, 'subject')->textInput(); ?> field($model, 'body')->textArea(array('rows' => 6)); ?> + field($model, 'verifyCode'); + echo $field->begin(); + echo $field->label(); + $this->widget(Captcha::className()); + echo Html::activeTextInput($model, 'verifyCode', array('class' => 'input-medium')); + echo $field->error(); + echo $field->end(); + ?>
'btn btn-primary')); ?>
diff --git a/framework/assets.php b/framework/assets.php index 919011b..10a450a 100644 --- a/framework/assets.php +++ b/framework/assets.php @@ -28,4 +28,11 @@ return array( ), 'depends' => array('yii', 'yii/validation'), ), + 'yii/captcha' => array( + 'sourcePath' => __DIR__ . '/assets', + 'js' => array( + 'yii.captcha.js', + ), + 'depends' => array('yii'), + ), ); diff --git a/framework/assets/yii.activeForm.js b/framework/assets/yii.activeForm.js index 158ea74..d987879 100644 --- a/framework/assets/yii.activeForm.js +++ b/framework/assets/yii.activeForm.js @@ -116,8 +116,8 @@ }); }, - options: function() { - return this.data('yiiActiveForm').settings; + data: function() { + return this.data('yiiActiveForm'); }, submitForm: function () { @@ -384,4 +384,4 @@ } }; -})(window.jQuery); \ No newline at end of file +})(window.jQuery); diff --git a/framework/assets/yii.captcha.js b/framework/assets/yii.captcha.js new file mode 100644 index 0000000..9211edb --- /dev/null +++ b/framework/assets/yii.captcha.js @@ -0,0 +1,72 @@ +/** + * Yii Captcha widget. + * + * This is the JavaScript widget used by the yii\widgets\Captcha widget. + * + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + * @author Qiang Xue + * @since 2.0 + */ +(function ($) { + $.fn.yiiCaptcha = function (method) { + if (methods[method]) { + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); + } else if (typeof method === 'object' || !method) { + return methods.init.apply(this, arguments); + } else { + $.error('Method ' + method + ' does not exist on jQuery.yiiCaptcha'); + return false; + } + }; + + var defaults = { + refreshUrl: undefined, + hashKey: undefined + }; + + var methods = { + init: function (options) { + return this.each(function () { + var $e = $(this); + var settings = $.extend({}, defaults, options || {}); + $e.data('yiiCaptcha', { + settings: settings + }); + + $e.on('click.yiiCaptcha', function() { + methods.refresh.apply($e); + return false; + }); + + }); + }, + + refresh: function () { + var $e = this, + settings = this.data('yiiCaptcha').settings; + $.ajax({ + url: $e.data('yiiCaptcha').settings.refreshUrl, + dataType: 'json', + cache: false, + success: function(data) { + $e.attr('src', data['url']); + $('body').data(settings.hashKey, [data['hash1'], data['hash2']]); + } + }); + }, + + destroy: function () { + return this.each(function () { + $(window).unbind('.yiiCaptcha'); + $(this).removeData('yiiCaptcha'); + }); + }, + + data: function() { + return this.data('yiiCaptcha'); + } + }; +})(window.jQuery); + diff --git a/framework/validators/CaptchaValidator.php b/framework/validators/CaptchaValidator.php index 4eba9df..2e58cf2 100644 --- a/framework/validators/CaptchaValidator.php +++ b/framework/validators/CaptchaValidator.php @@ -21,6 +21,7 @@ use yii\helpers\Html; */ class CaptchaValidator extends Validator { + public $skipOnEmpty = false; /** * @var boolean whether the comparison is case sensitive. Defaults to false. */ @@ -70,7 +71,7 @@ class CaptchaValidator extends Validator /** * Returns the CAPTCHA action object. * @throws InvalidConfigException - * @return CaptchaAction the action object + * @return \yii\web\CaptchaAction the action object */ public function getCaptchaAction() { diff --git a/framework/web/CaptchaAction.php b/framework/web/CaptchaAction.php new file mode 100644 index 0000000..e3d6eaa --- /dev/null +++ b/framework/web/CaptchaAction.php @@ -0,0 +1,338 @@ + + * @since 2.0 + */ +class CaptchaAction extends Action +{ + /** + * The name of the GET parameter indicating whether the CAPTCHA image should be regenerated. + */ + const REFRESH_GET_VAR = 'refresh'; + /** + * @var integer how many times should the same CAPTCHA be displayed. Defaults to 3. + * A value less than or equal to 0 means the test is unlimited (available since version 1.1.2). + */ + public $testLimit = 3; + /** + * @var integer the width of the generated CAPTCHA image. Defaults to 120. + */ + public $width = 120; + /** + * @var integer the height of the generated CAPTCHA image. Defaults to 50. + */ + public $height = 50; + /** + * @var integer padding around the text. Defaults to 2. + */ + public $padding = 2; + /** + * @var integer the background color. For example, 0x55FF00. + * Defaults to 0xFFFFFF, meaning white color. + */ + public $backColor = 0xFFFFFF; + /** + * @var integer the font color. For example, 0x55FF00. Defaults to 0x2040A0 (blue color). + */ + public $foreColor = 0x2040A0; + /** + * @var boolean whether to use transparent background. Defaults to false. + */ + public $transparent = false; + /** + * @var integer the minimum length for randomly generated word. Defaults to 6. + */ + public $minLength = 6; + /** + * @var integer the maximum length for randomly generated word. Defaults to 7. + */ + public $maxLength = 7; + /** + * @var integer the offset between characters. Defaults to -2. You can adjust this property + * in order to decrease or increase the readability of the captcha. + **/ + public $offset = -2; + /** + * @var string the TrueType font file. This can be either a file path or path alias. + */ + public $fontFile = '@yii/web/SpicyRice.ttf'; + /** + * @var string the fixed verification code. When this is property is set, + * [[getVerifyCode()]] will always return the value of this property. + * This is mainly used in automated tests where we want to be able to reproduce + * the same verification code each time we run the tests. + * If not set, it means the verification code will be randomly generated. + */ + public $fixedVerifyCode; + + + /** + * Initializes the action. + * @throws InvalidConfigException if the font file does not exist. + */ + public function init() + { + $this->fontFile = Yii::getAlias($this->fontFile); + if (!is_file($this->fontFile)) { + throw new InvalidConfigException("The font file does not exist: {$this->fontFile}"); + } + } + + /** + * Runs the action. + */ + public function run() + { + if (isset($_GET[self::REFRESH_GET_VAR])) { + // AJAX request for regenerating code + $code = $this->getVerifyCode(true); + echo json_encode(array( + 'hash1' => $this->generateValidationHash($code), + 'hash2' => $this->generateValidationHash(strtolower($code)), + // we add a random 'v' parameter so that FireFox can refresh the image + // when src attribute of image tag is changed + 'url' => $this->controller->createUrl($this->id, array('v' => uniqid())), + )); + } else { + $this->renderImage($this->getVerifyCode()); + } + Yii::$app->end(); + } + + /** + * Generates a hash code that can be used for client side validation. + * @param string $code the CAPTCHA code + * @return string a hash code generated from the CAPTCHA code + */ + public function generateValidationHash($code) + { + for ($h = 0, $i = strlen($code) - 1; $i >= 0; --$i) { + $h += ord($code[$i]); + } + return $h; + } + + /** + * Gets the verification code. + * @param boolean $regenerate whether the verification code should be regenerated. + * @return string the verification code. + */ + public function getVerifyCode($regenerate = false) + { + if ($this->fixedVerifyCode !== null) { + return $this->fixedVerifyCode; + } + + $session = Yii::$app->session; + $session->open(); + $name = $this->getSessionKey(); + if ($session[$name] === null || $regenerate) { + $session[$name] = $this->generateVerifyCode(); + $session[$name . 'count'] = 1; + } + return $session[$name]; + } + + /** + * Validates the input to see if it matches the generated code. + * @param string $input user input + * @param boolean $caseSensitive whether the comparison should be case-sensitive + * @return boolean whether the input is valid + */ + public function validate($input, $caseSensitive) + { + $code = $this->getVerifyCode(); + $valid = $caseSensitive ? ($input === $code) : strcasecmp($input, $code) === 0; + $session = Yii::$app->session; + $session->open(); + $name = $this->getSessionKey() . 'count'; + $session[$name] = $session[$name] + 1; + if ($session[$name] > $this->testLimit && $this->testLimit > 0) { + $this->getVerifyCode(true); + } + return $valid; + } + + /** + * Generates a new verification code. + * @return string the generated verification code + */ + protected function generateVerifyCode() + { + if ($this->minLength < 3) { + $this->minLength = 3; + } + if ($this->maxLength > 20) { + $this->maxLength = 20; + } + if ($this->minLength > $this->maxLength) { + $this->maxLength = $this->minLength; + } + $length = mt_rand($this->minLength, $this->maxLength); + + $letters = 'bcdfghjklmnpqrstvwxyz'; + $vowels = 'aeiou'; + $code = ''; + for ($i = 0; $i < $length; ++$i) { + if ($i % 2 && mt_rand(0, 10) > 2 || !($i % 2) && mt_rand(0, 10) > 9) { + $code .= $vowels[mt_rand(0, 4)]; + } else { + $code .= $letters[mt_rand(0, 20)]; + } + } + + return $code; + } + + /** + * Returns the session variable name used to store verification code. + * @return string the session variable name + */ + protected function getSessionKey() + { + return '__captcha/' . $this->getUniqueId(); + } + + /** + * Renders the CAPTCHA image. + * @param string $code the verification code + */ + protected function renderImage($code) + { + if (Captcha::checkRequirements() === 'gd') { + $this->renderImageByGD($code); + } else { + $this->renderImageByImagick($code); + } + } + + /** + * Renders the CAPTCHA image based on the code using GD library. + * @param string $code the verification code + */ + protected function renderImageByGD($code) + { + $image = imagecreatetruecolor($this->width, $this->height); + + $backColor = imagecolorallocate($image, + (int)($this->backColor % 0x1000000 / 0x10000), + (int)($this->backColor % 0x10000 / 0x100), + $this->backColor % 0x100); + imagefilledrectangle($image, 0, 0, $this->width, $this->height, $backColor); + imagecolordeallocate($image, $backColor); + + if ($this->transparent) { + imagecolortransparent($image, $backColor); + } + + $foreColor = imagecolorallocate($image, + (int)($this->foreColor % 0x1000000 / 0x10000), + (int)($this->foreColor % 0x10000 / 0x100), + $this->foreColor % 0x100); + + if ($this->fontFile === null) { + $this->fontFile = dirname(__FILE__) . '/SpicyRice.ttf'; + } + + $length = strlen($code); + $box = imagettfbbox(30, 0, $this->fontFile, $code); + $w = $box[4] - $box[0] + $this->offset * ($length - 1); + $h = $box[1] - $box[5]; + $scale = min(($this->width - $this->padding * 2) / $w, ($this->height - $this->padding * 2) / $h); + $x = 10; + $y = round($this->height * 27 / 40); + for ($i = 0; $i < $length; ++$i) { + $fontSize = (int)(rand(26, 32) * $scale * 0.8); + $angle = rand(-10, 10); + $letter = $code[$i]; + $box = imagettftext($image, $fontSize, $angle, $x, $y, $foreColor, $this->fontFile, $letter); + $x = $box[2] + $this->offset; + } + + imagecolordeallocate($image, $foreColor); + + header('Pragma: public'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Content-Transfer-Encoding: binary'); + header("Content-type: image/png"); + imagepng($image); + imagedestroy($image); + } + + /** + * Renders the CAPTCHA image based on the code using ImageMagick library. + * @param string $code the verification code + */ + protected function renderImageByImagick($code) + { + $backColor = $this->transparent ? new \ImagickPixel('transparent') : new \ImagickPixel('#' . dechex($this->backColor)); + $foreColor = new \ImagickPixel('#' . dechex($this->foreColor)); + + $image = new \Imagick(); + $image->newImage($this->width, $this->height, $backColor); + + if ($this->fontFile === null) { + $this->fontFile = dirname(__FILE__) . '/SpicyRice.ttf'; + } + + $draw = new \ImagickDraw(); + $draw->setFont($this->fontFile); + $draw->setFontSize(30); + $fontMetrics = $image->queryFontMetrics($draw, $code); + + $length = strlen($code); + $w = (int)($fontMetrics['textWidth']) - 8 + $this->offset * ($length - 1); + $h = (int)($fontMetrics['textHeight']) - 8; + $scale = min(($this->width - $this->padding * 2) / $w, ($this->height - $this->padding * 2) / $h); + $x = 10; + $y = round($this->height * 27 / 40); + for ($i = 0; $i < $length; ++$i) { + $draw = new \ImagickDraw(); + $draw->setFont($this->fontFile); + $draw->setFontSize((int)(rand(26, 32) * $scale * 0.8)); + $draw->setFillColor($foreColor); + $image->annotateImage($draw, $x, $y, rand(-10, 10), $code[$i]); + $fontMetrics = $image->queryFontMetrics($draw, $code[$i]); + $x += (int)($fontMetrics['textWidth']) + $this->offset; + } + + header('Pragma: public'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Content-Transfer-Encoding: binary'); + header("Content-type: image/png"); + $image->setImageFormat('png'); + echo $image; + } +} diff --git a/framework/web/SpicyRice.md b/framework/web/SpicyRice.md new file mode 100644 index 0000000..d99f3dc --- /dev/null +++ b/framework/web/SpicyRice.md @@ -0,0 +1,11 @@ +## Spicy Rice font + +* **Author:** Brian J. Bonislawsky, Astigmatic (AOETI, Astigmatic One Eye Typographic Institute) +* **License:** SIL Open Font License (OFL), version 1.1, [notes and FAQ](http://scripts.sil.org/OFL) + +## Links + +* [Astigmatic](http://www.astigmatic.com/) +* [Google WebFonts](http://www.google.com/webfonts/specimen/Spicy+Rice) +* [fontsquirrel.com](http://www.fontsquirrel.com/fonts/spicy-rice) +* [fontspace.com](http://www.fontspace.com/astigmatic-one-eye-typographic-institute/spicy-rice) diff --git a/framework/web/SpicyRice.ttf b/framework/web/SpicyRice.ttf new file mode 100644 index 0000000..638436c Binary files /dev/null and b/framework/web/SpicyRice.ttf differ diff --git a/framework/widgets/ActiveField.php b/framework/widgets/ActiveField.php index aaa9470..9f3f201 100644 --- a/framework/widgets/ActiveField.php +++ b/framework/widgets/ActiveField.php @@ -152,7 +152,7 @@ class ActiveField extends Component $options['enableAjaxValidation'] = 1; } - if ($enableClientValidation || $enableAjaxValidation) { + if ($enableClientValidation && !empty($options['validate']) || $enableAjaxValidation) { $inputID = Html::getInputId($this->model, $this->attribute); $options['name'] = $inputID; $names = array( diff --git a/framework/widgets/Captcha.php b/framework/widgets/Captcha.php new file mode 100644 index 0000000..918e30c --- /dev/null +++ b/framework/widgets/Captcha.php @@ -0,0 +1,102 @@ + + * @since 2.0 + */ +class Captcha extends Widget +{ + /** + * @var string the route of the action that generates the CAPTCHA images. + * The action represented by this route must be an action of [[CaptchaAction]]. + */ + public $captchaAction = 'site/captcha'; + /** + * @var array HTML attributes to be applied to the rendered image element. + */ + public $options = array(); + + + /** + * Renders the widget. + */ + public function run() + { + $this->checkRequirements(); + + if (!isset($this->options['id'])) { + $this->options['id'] = $this->getId(); + } + $id = $this->options['id']; + $options = Json::encode($this->getClientOptions()); + $this->view->registerAssetBundle('yii/captcha'); + $this->view->registerJs("jQuery('#$id').yiiCaptcha($options);"); + $url = Yii::$app->getUrlManager()->createUrl($this->captchaAction, array('v' => uniqid())); + echo Html::img($url, $this->options); + } + + /** + * Returns the options for the captcha JS widget. + * @return array the options + */ + protected function getClientOptions() + { + $options = array( + 'refreshUrl' => Html::url(array($this->captchaAction, CaptchaAction::REFRESH_GET_VAR => 1)), + 'hashKey' => "yiiCaptcha/{$this->captchaAction}", + ); + return $options; + } + + /** + * Checks if there is graphic extension available to generate CAPTCHA images. + * This method will check the existence of ImageMagick and GD extensions. + * @return string the name of the graphic extension, either "imagick" or "gd". + * @throws InvalidConfigException if neither ImageMagick nor GD is installed. + */ + public static function checkRequirements() + { + if (extension_loaded('imagick')) { + $imagick = new \Imagick(); + $imagickFormats = $imagick->queryFormats('PNG'); + if (in_array('PNG', $imagickFormats)) { + return 'imagick'; + } + } + if (extension_loaded('gd')) { + $gdInfo = gd_info(); + if (!empty($gdInfo['FreeType Support'])) { + return 'gd'; + } + } + throw new InvalidConfigException('GD with FreeType or ImageMagick PHP extensions are required.'); + } +}