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