xref: /plugin/captcha/helper.php (revision 9d63c05f1641fbe8fa2ecb519db077ab2be4b58d)
1<?php
2/**
3 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
4 * @author     Andreas Gohr <andi@splitbrain.org>
5 */
6// must be run within Dokuwiki
7if(!defined('DOKU_INC')) die();
8if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC.'lib/plugins/');
9require_once(DOKU_INC.'inc/blowfish.php');
10
11class helper_plugin_captcha extends DokuWiki_Plugin {
12
13    protected $field_in = 'plugin__captcha';
14    protected $field_sec = 'plugin__captcha_secret';
15    protected $field_hp = 'plugin__captcha_honeypot';
16
17    /**
18     * Constructor. Initializes field names
19     */
20    public function __construct() {
21        $this->field_in  = md5($this->_fixedIdent().$this->field_in);
22        $this->field_sec = md5($this->_fixedIdent().$this->field_sec);
23        $this->field_hp  = md5($this->_fixedIdent().$this->field_hp);
24    }
25
26    /**
27     * Check if the CAPTCHA should be used. Always check this before using the methods below.
28     *
29     * @return bool true when the CAPTCHA should be used
30     */
31    public function isEnabled() {
32        if(!$this->getConf('forusers') && $_SERVER['REMOTE_USER']) return false;
33        return true;
34    }
35
36    /**
37     * Returns the HTML to display the CAPTCHA with the chosen method
38     */
39    public function getHTML() {
40        global $ID;
41
42        $rand = (float) (rand(0, 10000)) / 10000;
43        if($this->getConf('mode') == 'math') {
44            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
45            $code = $code[0];
46            $text = $this->getLang('fillmath');
47        } else {
48            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
49            $text = $this->getLang('fillcaptcha');
50        }
51        $secret = PMA_blowfish_encrypt($rand, auth_cookiesalt());
52
53        $txtlen = $this->getConf('lettercount');
54
55        $out = '';
56        $out .= '<div id="plugin__captcha_wrapper">';
57        $out .= '<input type="hidden" name="'.$this->field_sec.'" value="'.hsc($secret).'" />';
58        $out .= '<label for="plugin__captcha">'.$text.'</label> ';
59
60        switch($this->getConf('mode')) {
61            case 'math':
62            case 'text':
63                $out .= $this->_obfuscateText($code);
64                break;
65            case 'js':
66                $out .= '<span id="plugin__captcha_code">'.$this->_obfuscateText($code).'</span>';
67                break;
68            case 'image':
69                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'" '.
70                    ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> ';
71                break;
72            case 'audio':
73                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/img.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'" '.
74                    ' width="'.$this->getConf('width').'" height="'.$this->getConf('height').'" alt="" /> ';
75                $out .= '<a href="'.DOKU_BASE.'lib/plugins/captcha/wav.php?secret='.rawurlencode($secret).'&amp;id='.$ID.'"'.
76                    ' class="JSnocheck" title="'.$this->getLang('soundlink').'">';
77                $out .= '<img src="'.DOKU_BASE.'lib/plugins/captcha/sound.png" width="16" height="16"'.
78                    ' alt="'.$this->getLang('soundlink').'" /></a>';
79                break;
80            case 'figlet':
81                require_once(dirname(__FILE__).'/figlet.php');
82                $figlet = new phpFiglet();
83                if($figlet->loadfont(dirname(__FILE__).'/figlet.flf')) {
84                    $out .= '<pre>';
85                    $out .= rtrim($figlet->fetch($code));
86                    $out .= '</pre>';
87                } else {
88                    msg('Failed to load figlet.flf font file. CAPTCHA broken', -1);
89                }
90                break;
91        }
92        $out .= ' <input type="text" size="'.$txtlen.'" maxlength="'.$txtlen.'" name="'.$this->field_in.'" class="edit" /> ';
93
94        // add honeypot field
95        $out .= '<label class="no">'.$this->getLang('honeypot').'<input type="text" name="'.$this->field_hp.'" /></label>';
96        $out .= '</div>';
97        return $out;
98    }
99
100    /**
101     * Checks if the the CAPTCHA was solved correctly
102     *
103     * @param  bool $msg when true, an error will be signalled through the msg() method
104     * @return bool true when the answer was correct, otherwise false
105     */
106    public function check($msg = true) {
107        // compare provided string with decrypted captcha
108        $rand = PMA_blowfish_decrypt($_REQUEST[$this->field_sec], auth_cookiesalt());
109
110        if($this->getConf('mode') == 'math') {
111            $code = $this->_generateMATH($this->_fixedIdent(), $rand);
112            $code = $code[1];
113        } else {
114            $code = $this->_generateCAPTCHA($this->_fixedIdent(), $rand);
115        }
116
117        if(!$_REQUEST[$this->field_sec] ||
118            !$_REQUEST[$this->field_in] ||
119            strtoupper($_REQUEST[$this->field_in]) != $code ||
120            trim($_REQUEST[$this->field_hp]) !== ''
121        ) {
122            if($msg) msg($this->getLang('testfailed'), -1);
123            return false;
124        }
125        return true;
126    }
127
128    /**
129     * Build a semi-secret fixed string identifying the current page and user
130     *
131     * This string is always the same for the current user when editing the same
132     * page revision, but only for one day. Editing a page before midnight and saving
133     * after midnight will result in a failed CAPTCHA once, but makes sure it can
134     * not be reused which is especially important for the registration form where the
135     * $ID usually won't change.
136     *
137     * @return string
138     */
139    public function _fixedIdent() {
140        global $ID;
141        $lm = @filemtime(wikiFN($ID));
142        $td = date('Y-m-d');
143        return auth_browseruid().
144            auth_cookiesalt().
145            $ID.$lm.$td;
146    }
147
148    /**
149     * Adds random space characters within the given text
150     *
151     * Keeps subsequent numbers without spaces (for math problem)
152     *
153     * @param $text
154     * @return string
155     */
156    protected function _obfuscateText($text){
157        $new = '';
158
159        $spaces = array(
160            "\r",
161            "\n",
162            "\r\n",
163            ' ',
164            "\xC2\xA0", // \u00A0    NO-BREAK SPACE
165            "\xE2\x80\x80", // \u2000    EN QUAD
166            "\xE2\x80\x81", // \u2001    EM QUAD
167            "\xE2\x80\x82", // \u2002    EN SPACE
168   //         "\xE2\x80\x83", // \u2003    EM SPACE
169            "\xE2\x80\x84", // \u2004    THREE-PER-EM SPACE
170            "\xE2\x80\x85", // \u2005    FOUR-PER-EM SPACE
171            "\xE2\x80\x86", // \u2006    SIX-PER-EM SPACE
172            "\xE2\x80\x87", // \u2007    FIGURE SPACE
173            "\xE2\x80\x88", // \u2008    PUNCTUATION SPACE
174            "\xE2\x80\x89", // \u2009    THIN SPACE
175            "\xE2\x80\x8A", // \u200A    HAIR SPACE
176            "\xE2\x80\xAF", // \u202F    NARROW NO-BREAK SPACE
177            "\xE2\x81\x9F", // \u205F    MEDIUM MATHEMATICAL SPACE
178
179            "\xE1\xA0\x8E\r\n", // \u180E    MONGOLIAN VOWEL SEPARATOR
180            "\xE2\x80\x8B\r\n", // \u200B    ZERO WIDTH SPACE
181            "\xEF\xBB\xBF\r\n", // \uFEFF    ZERO WIDTH NO-BREAK SPACE
182        );
183
184        $len = strlen($text);
185        for($i=0; $i<$len-1; $i++){
186            $new .= $text{$i};
187
188            if(!is_numeric($text{$i+1})){
189                $new .= $spaces[array_rand($spaces)];
190            }
191        }
192        $new .= $text{$len-1};
193        return $new;
194    }
195
196    /**
197     * Generates a random char string
198     *
199     * @param $fixed string the fixed part, any string
200     * @param $rand  float  some random number between 0 and 1
201     * @return string
202     */
203    public function _generateCAPTCHA($fixed, $rand) {
204        $fixed   = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int
205        $numbers = md5($rand * $fixed); // combine both values
206
207        // now create the letters
208        $code = '';
209        for($i = 0; $i < ($this->getConf('lettercount') * 2); $i += 2) {
210            $code .= chr(floor(hexdec($numbers[$i].$numbers[$i + 1]) / 10) + 65);
211        }
212
213        return $code;
214    }
215
216    /**
217     * Create a mathematical task and its result
218     *
219     * @param $fixed string the fixed part, any string
220     * @param $rand  float  some random number between 0 and 1
221     * @return array taks, result
222     */
223    protected function _generateMATH($fixed, $rand) {
224        $fixed   = hexdec(substr(md5($fixed), 5, 5)); // use part of the md5 to generate an int
225        $numbers = md5($rand * $fixed); // combine both values
226
227        // first letter is the operator (+/-)
228        $op  = (hexdec($numbers[0]) > 8) ? -1 : 1;
229        $num = array(hexdec($numbers[1].$numbers[2]), hexdec($numbers[3]));
230
231        // we only want positive results
232        if(($op < 0) && ($num[0] < $num[1])) rsort($num);
233
234        // prepare result and task text
235        $res  = $num[0] + ($num[1] * $op);
236        $task = $num[0].(($op < 0) ? '-' : '+').$num[1].'=?';
237
238        return array($task, $res);
239    }
240
241    /**
242     * Create a CAPTCHA image
243     *
244     * @param string $text the letters to display
245     */
246    public function _imageCAPTCHA($text) {
247        $w = $this->getConf('width');
248        $h = $this->getConf('height');
249
250        $fonts = glob(dirname(__FILE__).'/fonts/*.ttf');
251
252        // create a white image
253        $img = imagecreatetruecolor($w, $h);
254        $white = imagecolorallocate($img, 255, 255, 255);
255        imagefill($img, 0, 0, $white);
256
257        // add some lines as background noise
258        for($i = 0; $i < 30; $i++) {
259            $color = imagecolorallocate($img, rand(100, 250), rand(100, 250), rand(100, 250));
260            imageline($img, rand(0, $w), rand(0, $h), rand(0, $w), rand(0, $h), $color);
261        }
262
263        // draw the letters
264        $txtlen = strlen($text);
265        for($i = 0; $i < $txtlen; $i++) {
266            $font  = $fonts[array_rand($fonts)];
267            $color = imagecolorallocate($img, rand(0, 100), rand(0, 100), rand(0, 100));
268            $size  = rand(floor($h / 1.8), floor($h * 0.7));
269            $angle = rand(-35, 35);
270
271            $x       = ($w * 0.05) + $i * floor($w * 0.9 / $txtlen);
272            $cheight = $size + ($size * 0.5);
273            $y       = floor($h / 2 + $cheight / 3.8);
274
275            imagettftext($img, $size, $angle, $x, $y, $color, $font, $text[$i]);
276        }
277
278        header("Content-type: image/png");
279        imagepng($img);
280        imagedestroy($img);
281    }
282
283}
284