Added files (copied from FancyCaptcha) to prepare our CookieCaptcha
[toast/cookiecaptcha.git] / CookieCaptcha.class.php
1 <?php
2
3 class FancyCaptcha extends SimpleCaptcha {
4         /**
5          * @return FileBackend
6          */
7         public function getBackend() {
8                 global $wgCaptchaFileBackend, $wgCaptchaDirectory;
9
10                 if ( $wgCaptchaFileBackend ) {
11                         return FileBackendGroup::singleton()->get( $wgCaptchaFileBackend );
12                 } else {
13                         static $backend = null;
14                         if ( !$backend ) {
15                                 $backend = new FSFileBackend( array(
16                                         'name'           => 'captcha-backend',
17                                         'lockManager'    => 'nullLockManager',
18                                         'containerPaths' => array( 'captcha-render' => $wgCaptchaDirectory ),
19                                         'fileMode'       => 777
20                                 ) );
21                         }
22                         return $backend;
23                 }
24         }
25
26         /**
27          * @return integer Estimate of the number of captchas files
28          */
29         public function estimateCaptchaCount() {
30                 global $wgCaptchaDirectoryLevels;
31
32                 $factor = 1;
33                 $sampleDir = $this->getBackend()->getRootStoragePath() . '/captcha-render';
34                 if ( $wgCaptchaDirectoryLevels >= 1 ) { // 1/16 sample if 16 shards
35                         $sampleDir .= '/' . dechex( mt_rand( 0, 15 ) );
36                         $factor = 16;
37                 }
38                 if ( $wgCaptchaDirectoryLevels >= 3 ) { // 1/256 sample if 4096 shards
39                         $sampleDir .= '/' . dechex( mt_rand( 0, 15 ) );
40                         $factor = 256;
41                 }
42
43                 $count = 0;
44                 foreach ( $this->getBackend()->getFileList( array( 'dir' => $sampleDir ) ) as $file ) {
45                         ++$count;
46                 }
47
48                 return ( $count * $factor );
49         }
50
51         /**
52          * Check if the submitted form matches the captcha session data provided
53          * by the plugin when the form was generated.
54          *
55          * @param string $answer
56          * @param array $info
57          * @return bool
58          */
59         function keyMatch( $answer, $info ) {
60                 global $wgCaptchaSecret;
61
62                 $digest = $wgCaptchaSecret . $info['salt'] . $answer . $wgCaptchaSecret . $info['salt'];
63                 $answerHash = substr( md5( $digest ), 0, 16 );
64
65                 if ( $answerHash == $info['hash'] ) {
66                         wfDebug( "FancyCaptcha: answer hash matches expected {$info['hash']}\n" );
67                         return true;
68                 } else {
69                         wfDebug( "FancyCaptcha: answer hashes to $answerHash, expected {$info['hash']}\n" );
70                         return false;
71                 }
72         }
73
74         function addCaptchaAPI( &$resultArr ) {
75                 $info = $this->pickImage();
76                 if ( !$info ) {
77                         $resultArr['captcha']['error'] = 'Out of images';
78                         return;
79                 }
80                 $index = $this->storeCaptcha( $info );
81                 $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
82                 $resultArr['captcha']['type'] = 'image';
83                 $resultArr['captcha']['mime'] = 'image/png';
84                 $resultArr['captcha']['id'] = $index;
85                 $resultArr['captcha']['url'] = $title->getLocalUrl( 'wpCaptchaId=' . urlencode( $index ) );
86         }
87
88         /**
89          * Insert the captcha prompt into the edit form.
90          */
91         function getForm() {
92                 $info = $this->pickImage();
93                 if ( !$info ) {
94                         throw new MWException( "Ran out of captcha images" );
95                 }
96
97                 // Generate a random key for use of this captcha image in this session.
98                 // This is needed so multiple edits in separate tabs or windows can
99                 // go through without extra pain.
100                 $index = $this->storeCaptcha( $info );
101
102                 wfDebug( "Captcha id $index using hash ${info['hash']}, salt ${info['salt']}.\n" );
103
104                 $title = SpecialPage::getTitleFor( 'Captcha', 'image' );
105
106                 return "<p>" .
107                         Html::element( 'img', array(
108                                 'src'    => $title->getLocalUrl( 'wpCaptchaId=' . urlencode( $index ) ),
109                                 'width'  => $info['width'],
110                                 'height' => $info['height'],
111                                 'alt'    => '' ) ) .
112                         "</p>\n" .
113                         Html::element( 'input', array(
114                                 'type'  => 'hidden',
115                                 'name'  => 'wpCaptchaId',
116                                 'id'    => 'wpCaptchaId',
117                                 'value' => $index ) ) .
118                         '<p>' .
119                         Html::element( 'label', array(
120                                 'for' => 'wpCaptchaWord',
121                         ), parent::getMessage( 'label' ) . wfMessage( 'colon-separator' )->text() ) .
122                         Html::element( 'input', array(
123                                 'name' => 'wpCaptchaWord',
124                                 'id'   => 'wpCaptchaWord',
125                                 'type' => 'text',
126                                 'autocorrect' => 'off',
127                                 'autocapitalize' => 'off',
128                                 'required' => 'required',
129                                 'tabindex' => 1 ) ) . // tab in before the edit textarea
130                         "</p>\n";
131         }
132
133         /**
134          * Select a previously generated captcha image from the queue.
135          * @return mixed tuple of (salt key, text hash) or false if no image to find
136          */
137         protected function pickImage() {
138                 global $wgCaptchaDirectoryLevels;
139
140                 $lockouts = 0; // number of times another process claimed a file before this one
141                 $baseDir = $this->getBackend()->getRootStoragePath() . '/captcha-render';
142                 return $this->pickImageDir( $baseDir, $wgCaptchaDirectoryLevels, $lockouts );
143         }
144
145         /**
146          * @param $directory string
147          * @param $levels integer
148          * @param $lockouts integer
149          * @return Array|bool
150          */
151         protected function pickImageDir( $directory, $levels, &$lockouts ) {
152                 global $wgMemc;
153
154                 if ( $levels <= 0 ) { // $directory has regular files
155                         return $this->pickImageFromDir( $directory, $lockouts );
156                 }
157
158                 $backend = $this->getBackend();
159
160                 $key  = "fancycaptcha:dirlist:{$backend->getWikiId()}:" . sha1( $directory );
161                 $dirs = $wgMemc->get( $key ); // check cache
162                 if ( !is_array( $dirs ) ) { // cache miss
163                         $dirs = array(); // subdirs actually present...
164                         foreach ( $backend->getTopDirectoryList( array( 'dir' => $directory ) ) as $entry ) {
165                                 if ( ctype_xdigit( $entry ) && strlen( $entry ) == 1 ) {
166                                         $dirs[] = $entry;
167                                 }
168                         }
169                         wfDebug( "Cache miss for $directory subdirectory listing.\n" );
170                         $wgMemc->set( $key, $dirs, 86400 );
171                 }
172
173                 if ( !count( $dirs ) ) {
174                         // Remove this directory if empty so callers don't keep looking here
175                         $backend->clean( array( 'dir' => $directory ) );
176                         return false; // none found
177                 }
178
179                 $place = mt_rand( 0, count( $dirs ) - 1 ); // pick a random subdir
180                 // In case all dirs are not filled, cycle through next digits...
181                 for ( $j = 0; $j < count( $dirs ); $j++ ) {
182                         $char = $dirs[( $place + $j ) % count( $dirs )];
183                         $info = $this->pickImageDir( "$directory/$char", $levels - 1, $lockouts );
184                         if ( $info ) {
185                                 return $info; // found a captcha
186                         } else {
187                                 wfDebug( "Could not find captcha in $directory.\n" );
188                                 $wgMemc->delete( $key ); // files changed on disk?
189                         }
190                 }
191
192                 return false; // didn't find any images in this directory... empty?
193         }
194
195         /**
196          * @param $directory string
197          * @param $lockouts integer
198          * @return Array|bool
199          */
200         protected function pickImageFromDir( $directory, &$lockouts ) {
201                 global $wgMemc;
202
203                 $backend = $this->getBackend();
204
205                 $key   = "fancycaptcha:filelist:{$backend->getWikiId()}:" . sha1( $directory );
206                 $files = $wgMemc->get( $key ); // check cache
207                 if ( !is_array( $files ) ) { // cache miss
208                         $files = array(); // captcha files
209                         foreach ( $backend->getTopFileList( array( 'dir' => $directory ) ) as $entry ) {
210                                 $files[] = $entry;
211                                 if ( count( $files ) >= 500 ) { // sanity
212                                         wfDebug( 'Skipping some captchas; $wgCaptchaDirectoryLevels set too low?.' );
213                                         break;
214                                 }
215                         }
216                         $wgMemc->set( $key, $files, 86400 );
217                         wfDebug( "Cache miss for $directory captcha listing.\n" );
218                 }
219
220                 if ( !count( $files ) ) {
221                         // Remove this directory if empty so callers don't keep looking here
222                         $backend->clean( array( 'dir' => $directory ) );
223                         return false;
224                 }
225
226                 $info = $this->pickImageFromList( $directory, $files, $lockouts );
227                 if ( !$info ) {
228                         wfDebug( "Could not find captcha in $directory.\n" );
229                         $wgMemc->delete( $key ); // files changed on disk?
230                 }
231
232                 return $info;
233         }
234
235         /**
236          * @param $directory string
237          * @param $files array
238          * @param $lockouts integer
239          * @return boolean
240          */
241         protected function pickImageFromList( $directory, array $files, &$lockouts ) {
242                 global $wgMemc, $wgCaptchaDeleteOnSolve;
243
244                 if ( !count( $files ) ) {
245                         return false; // none found
246                 }
247
248                 $backend  = $this->getBackend();
249                 $place    = mt_rand( 0, count( $files ) - 1 ); // pick a random file
250                 $misses   = 0; // number of files in listing that don't actually exist
251                 for ( $j = 0; $j < count( $files ); $j++ ) {
252                         $entry = $files[( $place + $j ) % count( $files )];
253                         if ( preg_match( '/^image_([0-9a-f]+)_([0-9a-f]+)\\.png$/', $entry, $matches ) ) {
254                                 if ( $wgCaptchaDeleteOnSolve ) { // captcha will be deleted when solved
255                                         $key = "fancycaptcha:filelock:{$backend->getWikiId()}:" . sha1( $entry );
256                                         // Try to claim this captcha for 10 minutes (for the user to solve)...
257                                         if ( ++$lockouts <= 10 && !$wgMemc->add( $key, '1', 600 ) ) {
258                                                 continue; // could not acquire (skip it to avoid race conditions)
259                                         }
260                                 }
261                                 $fsFile = $backend->getLocalReference( array( 'src' => "$directory/$entry" ) );
262                                 if ( !$fsFile || !$fsFile->exists() ) {
263                                         if ( ++$misses >= 5 ) { // too many files in the listing don't exist
264                                                 break; // listing cache too stale? break out so it will be cleared
265                                         }
266                                         continue; // try next file
267                                 }
268                                 $size = getimagesize( $fsFile->getPath() );
269                                 return array(
270                                         'salt'   => $matches[1],
271                                         'hash'   => $matches[2],
272                                         'width'  => $size[0],
273                                         'height' => $size[1],
274                                         'viewed' => false,
275                                 );
276                         }
277                 }
278
279                 return false; // none found
280         }
281
282         function showImage() {
283                 global $wgOut;
284
285                 $wgOut->disable();
286
287                 $info = $this->retrieveCaptcha();
288                 if ( $info ) {
289                         $timestamp = new MWTimestamp();
290                         $info['viewed'] = $timestamp->getTimestamp();
291                         $this->storeCaptcha( $info );
292
293                         $salt = $info['salt'];
294                         $hash = $info['hash'];
295
296                         return $this->getBackend()->streamFile( array(
297                                 'src'     => $this->imagePath( $salt, $hash ),
298                                 'headers' => array( "Cache-Control: private, s-maxage=0, max-age=3600" )
299                         ) )->isOK();
300                 }
301
302                 wfHttpError( 500, 'Internal Error', 'Requested bogus captcha image' );
303                 return false;
304         }
305
306         /**
307          * @param $salt string
308          * @param $hash string
309          * @return string
310          */
311         public function imagePath( $salt, $hash ) {
312                 global $wgCaptchaDirectoryLevels;
313
314                 $file = $this->getBackend()->getRootStoragePath() . '/captcha-render/';
315                 for ( $i = 0; $i < $wgCaptchaDirectoryLevels; $i++ ) {
316                         $file .= $hash{ $i } . '/';
317                 }
318                 $file .= "image_{$salt}_{$hash}.png";
319
320                 return $file;
321         }
322
323         /**
324          * @param $basename string
325          * @return Array (salt, hash)
326          * @throws MWException
327          */
328         public function hashFromImageName( $basename ) {
329                 if ( preg_match( '/^image_([0-9a-f]+)_([0-9a-f]+)\\.png$/', $basename, $matches ) ) {
330                         return array( $matches[1], $matches[2] );
331                 } else {
332                         throw new MWException( "Invalid filename '$basename'.\n" );
333                 }
334         }
335
336         /**
337          * Show a message asking the user to enter a captcha on edit
338          * The result will be treated as wiki text
339          *
340          * @param $action string Action being performed
341          * @return string
342          */
343         function getMessage( $action ) {
344                 $name = 'fancycaptcha-' . $action;
345                 $text = wfMessage( $name )->text();
346                 # Obtain a more tailored message, if possible, otherwise, fall back to
347                 # the default for edits
348                 return wfMessage( $name, $text )->isDisabled() ?
349                         wfMessage( 'fancycaptcha-edit' )->text() : $text;
350         }
351
352         /**
353          * Delete a solved captcha image, if $wgCaptchaDeleteOnSolve is true.
354          */
355         function passCaptcha() {
356                 global $wgCaptchaDeleteOnSolve;
357
358                 $info = $this->retrieveCaptcha(); // get the captcha info before it gets deleted
359                 $pass = parent::passCaptcha();
360
361                 if ( $pass && $wgCaptchaDeleteOnSolve ) {
362                         $this->getBackend()->quickDelete( array(
363                                 'src' => $this->imagePath( $info['salt'], $info['hash'] )
364                         ) );
365                 }
366
367                 return $pass;
368         }
369 }