(bug 13918), make CAPTCHA less obtrusive. Added a new global $wgCaptchaBadLoginAttemp...
[toast/cookiecaptcha.git] / ConfirmEdit_body.php
1 <?php
2
3 class ConfirmEditHooks {
4         static function getInstance() {
5                 global $wgCaptcha, $wgCaptchaClass, $wgExtensionMessagesFiles;
6                 static $done = false;
7                 if ( !$done ) {
8                         $done = true;
9                         wfLoadExtensionMessages( 'ConfirmEdit' );
10                         if ( isset( $wgExtensionMessagesFiles[$wgCaptchaClass] ) ) {
11                                 wfLoadExtensionMessages( $wgCaptchaClass );
12                         }
13                         $wgCaptcha = new $wgCaptchaClass;
14                 }
15                 return $wgCaptcha;
16         }
17
18         static function confirmEdit( &$editPage, $newtext, $section ) {
19                 return self::getInstance()->confirmEdit( $editPage, $newtext, $section );
20         }
21
22         static function confirmEditMerged( &$editPage, $newtext ) {
23                 return self::getInstance()->confirmEditMerged( $editPage, $newtext );
24         }
25         
26         static function confirmEditAPI( &$editPage, $newtext, &$resultArr ) {
27                 return self::getInstance()->confirmEditAPI( $editPage, $newtext, $resultArr );
28         }
29
30         static function injectUserCreate( &$template ) {
31                 return self::getInstance()->injectUserCreate( $template );
32         }
33
34         static function confirmUserCreate( $u, &$message ) {
35                 return self::getInstance()->confirmUserCreate( $u, $message );
36         }
37
38         static function triggerUserLogin( $user, $password, $retval ) {
39                 return self::getInstance()->triggerUserLogin( $user, $password, $retval );
40         }
41
42         static function injectUserLogin( &$template ) {
43                 return self::getInstance()->injectUserLogin( $template );
44         }
45
46         static function confirmUserLogin( $u, $pass, &$retval ) {
47                 return self::getInstance()->confirmUserLogin( $u, $pass, $retval );
48         }
49 }
50
51 class CaptchaSpecialPage extends UnlistedSpecialPage {
52         function execute( $par ) {
53                 $this->setHeaders();
54                 $instance = ConfirmEditHooks::getInstance();
55                 switch( $par ) {
56                 case "image":
57                         return $instance->showImage();
58                 case "help":
59                 default:
60                         return $instance->showHelp();
61                 }
62         }
63 }
64
65
66 class SimpleCaptcha {
67         function SimpleCaptcha() {
68                 global $wgCaptchaStorageClass;
69                 $this->storage = new $wgCaptchaStorageClass;
70         }
71         
72         function getCaptcha() {
73                 $a = mt_rand(0, 100);
74                 $b = mt_rand(0, 10);
75                 $op = mt_rand(0, 1) ? '+' : '-';
76
77                 $test = "$a $op $b";
78                 $answer = ($op == '+') ? ($a + $b) : ($a - $b);
79                 return array('question' => $test, 'answer' => $answer);
80         }
81         
82         function addCaptchaAPI(&$resultArr) {
83                 $captcha = $this->getCaptcha();
84                 $index = $this->storeCaptcha( $captcha );
85                 $resultArr['captcha']['type'] = 'simple';
86                 $resultArr['captcha']['mime'] = 'text/plain';
87                 $resultArr['captcha']['id'] = $index;
88                 $resultArr['captcha']['question'] = $captcha['question'];
89         }
90         
91         /**
92          * Insert a captcha prompt into the edit form.
93          * This sample implementation generates a simple arithmetic operation;
94          * it would be easy to defeat by machine.
95          *
96          * Override this!
97          *
98          * @return string HTML
99          */
100         function getForm() {
101                 $captcha = $this->getCaptcha();
102                 $index = $this->storeCaptcha( $captcha );
103
104                 return "<p><label for=\"wpCaptchaWord\">{$captcha['question']}</label> = " .
105                         wfElement( 'input', array(
106                                 'name' => 'wpCaptchaWord',
107                                 'id'   => 'wpCaptchaWord',
108                                 'tabindex' => 1 ) ) . // tab in before the edit textarea
109                         "</p>\n" .
110                         wfElement( 'input', array(
111                                 'type'  => 'hidden',
112                                 'name'  => 'wpCaptchaId',
113                                 'id'    => 'wpCaptchaId',
114                                 'value' => $index ) );
115         }
116
117         /**
118          * Insert the captcha prompt into an edit form.
119          * @param OutputPage $out
120          */
121         function editCallback( &$out ) {
122                 $out->addWikiText( $this->getMessage( $this->action ) );
123                 $out->addHTML( $this->getForm() );
124         }
125
126         /**
127          * Show a message asking the user to enter a captcha on edit
128          * The result will be treated as wiki text
129          *
130          * @param $action Action being performed
131          * @return string
132          */
133         function getMessage( $action ) {
134                 $name = 'captcha-' . $action;
135                 $text = wfMsg( $name );
136                 # Obtain a more tailored message, if possible, otherwise, fall back to
137                 # the default for edits
138                 return wfEmptyMsg( $name, $text ) ? wfMsg( 'captcha-edit' ) : $text;
139         }
140
141         /**
142          * Inject whazawhoo
143          * @fixme if multiple thingies insert a header, could break
144          * @param SimpleTemplate $template
145          * @return bool true to keep running callbacks
146          */
147         function injectUserCreate( &$template ) {
148                 global $wgCaptchaTriggers, $wgOut;
149                 if( $wgCaptchaTriggers['createaccount'] ) {
150                         $template->set( 'header',
151                                 "<div class='captcha'>" .
152                                 $wgOut->parse( $this->getMessage( 'createaccount' ) ) .
153                                 $this->getForm() .
154                                 "</div>\n" );
155                 }
156                 return true;
157         }
158
159         /**
160          * Inject a captcha into the user login form after a failed
161          * password attempt as a speedbump for mass attacks.
162          * @fixme if multiple thingies insert a header, could break
163          * @param SimpleTemplate $template
164          * @return bool true to keep running callbacks
165          */
166         function injectUserLogin( &$template ) {
167                 if( $this->isBadLoginTriggered() ) {
168                         global $wgOut;
169                         $template->set( 'header',
170                                 "<div class='captcha'>" .
171                                 $wgOut->parse( $this->getMessage( 'badlogin' ) ) .
172                                 $this->getForm() .
173                                 "</div>\n" );
174                 }
175                 return true;
176         }
177         
178         /**
179          * When a bad login attempt is made, increment an expiring counter
180          * in the memcache cloud. Later checks for this may trigger a
181          * captcha display to prevent too many hits from the same place.
182          * @param User $user
183          * @param string $password
184          * @param int $retval authentication return value
185          * @return bool true to keep running callbacks
186          */
187         function triggerUserLogin( $user, $password, $retval ) {
188                 global $wgCaptchaTriggers, $wgCaptchaBadLoginExpiration, $wgMemc;
189                 if( $retval == LoginForm::WRONG_PASS && $wgCaptchaTriggers['badlogin'] ) {
190                         $key = $this->badLoginKey();
191                         $count = $wgMemc->get( $key );
192                         if( !$count ) {
193                                 $wgMemc->add( $key, 0, $wgCaptchaBadLoginExpiration );
194                         }
195                         $count = $wgMemc->incr( $key );
196                 }
197                 return true;
198         }
199         
200         /**
201          * Check if a bad login has already been registered for this
202          * IP address. If so, require a captcha.
203          * @return bool
204          * @access private
205          */
206         function isBadLoginTriggered() {
207                 global $wgMemc, $wgCaptchaBadLoginAttempts;
208                 return intval( $wgMemc->get( $this->badLoginKey() ) ) > $wgCaptchaBadLoginAttempts;
209         }
210         
211         /**
212          * Internal cache key for badlogin checks.
213          * @return string
214          * @access private
215          */
216         function badLoginKey() {
217                 return wfMemcKey( 'captcha', 'badlogin', 'ip', wfGetIP() );
218         }
219         
220         /**
221          * Check if the submitted form matches the captcha session data provided
222          * by the plugin when the form was generated.
223          *
224          * Override this!
225          *
226          * @param string $answer
227          * @param array $info
228          * @return bool
229          */
230         function keyMatch( $answer, $info ) {
231                 return $answer == $info['answer'];
232         }
233
234         // ----------------------------------
235
236         /**
237          * @param EditPage $editPage
238          * @param string $action (edit/create/addurl...)
239          * @return bool true if action triggers captcha on editPage's namespace
240          */
241         function captchaTriggers( &$editPage, $action) {
242                 global $wgCaptchaTriggers, $wgCaptchaTriggersOnNamespace;       
243                 //Special config for this NS?
244                 if (isset( $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action] ) )
245                         return $wgCaptchaTriggersOnNamespace[$editPage->mTitle->getNamespace()][$action];
246
247                 return ( !empty( $wgCaptchaTriggers[$action] ) ); //Default
248         }
249
250
251         /**
252          * @param EditPage $editPage
253          * @param string $newtext
254          * @param string $section
255          * @return bool true if the captcha should run
256          */
257         function shouldCheck( &$editPage, $newtext, $section, $merged = false ) {
258                 $this->trigger = '';
259                 $title = $editPage->mArticle->getTitle();
260
261                 global $wgUser;
262                 if( $wgUser->isAllowed( 'skipcaptcha' ) ) {
263                         wfDebug( "ConfirmEdit: user group allows skipping captcha\n" );
264                         return false;
265                 }
266                 global $wgCaptchaWhitelistIP;
267                 if( !empty( $wgCaptchaWhitelistIP ) ) {
268                         $ip = wfGetIp();
269                         foreach ( $wgCaptchaWhitelistIP as $range ) {
270                                 if ( IP::isInRange( $ip, $range ) ) {
271                                         return false;
272                                 }
273                         }
274                 }
275
276
277                 global $wgEmailAuthentication, $ceAllowConfirmedEmail;
278                 if( $wgEmailAuthentication && $ceAllowConfirmedEmail &&
279                         $wgUser->isEmailConfirmed() ) {
280                         wfDebug( "ConfirmEdit: user has confirmed mail, skipping captcha\n" );
281                         return false;
282                 }
283
284                 if( $this->captchaTriggers( $editPage, 'edit' ) ) {
285                         // Check on all edits
286                         global $wgUser;
287                         $this->trigger = sprintf( "edit trigger by '%s' at [[%s]]",
288                                 $wgUser->getName(),
289                                 $title->getPrefixedText() );
290                         $this->action = 'edit';
291                         wfDebug( "ConfirmEdit: checking all edits...\n" );
292                         return true;
293                 }
294
295                 if( $this->captchaTriggers( $editPage, 'create' )  && !$editPage->mTitle->exists() ) {
296                         //Check if creating a page
297                         global $wgUser;
298                         $this->trigger = sprintf( "Create trigger by '%s' at [[%s]]",
299                                 $wgUser->getName(),
300                                 $title->getPrefixedText() );
301                         $this->action = 'create';
302                         wfDebug( "ConfirmEdit: checking on page creation...\n" );
303                         return true;
304                 }
305
306                 if( $this->captchaTriggers( $editPage, 'addurl' ) ) {
307                         // Only check edits that add URLs
308                         if ( $merged ) {
309                                 // Get links from the database
310                                 $oldLinks = $this->getLinksFromTracker( $title );
311                                 // Share a parse operation with Article::doEdit()
312                                 $editInfo = $editPage->mArticle->prepareTextForEdit( $newtext );
313                                 $newLinks = array_keys( $editInfo->output->getExternalLinks() );
314                         } else {
315                                 // Get link changes in the slowest way known to man
316                                 $oldtext = $this->loadText( $editPage, $section );
317                                 $oldLinks = $this->findLinks( $oldtext );
318                                 $newLinks = $this->findLinks( $newtext );
319                         }
320
321                         $unknownLinks = array_filter( $newLinks, array( &$this, 'filterLink' ) );
322                         $addedLinks = array_diff( $unknownLinks, $oldLinks );
323                         $numLinks = count( $addedLinks );
324
325                         if( $numLinks > 0 ) {
326                                 global $wgUser;
327                                 $this->trigger = sprintf( "%dx url trigger by '%s' at [[%s]]: %s",
328                                         $numLinks,
329                                         $wgUser->getName(),
330                                         $title->getPrefixedText(),
331                                         implode( ", ", $addedLinks ) );
332                                 $this->action = 'addurl';
333                                 return true;
334                         }
335                 }
336
337                 global $wgCaptchaRegexes;
338                 if( !empty( $wgCaptchaRegexes ) ) {
339                         // Custom regex checks
340                         $oldtext = $this->loadText( $editPage, $section );
341
342                         foreach( $wgCaptchaRegexes as $regex ) {
343                                 $newMatches = array();
344                                 if( preg_match_all( $regex, $newtext, $newMatches ) ) {
345                                         $oldMatches = array();
346                                         preg_match_all( $regex, $oldtext, $oldMatches );
347
348                                         $addedMatches = array_diff( $newMatches[0], $oldMatches[0] );
349
350                                         $numHits = count( $addedMatches );
351                                         if( $numHits > 0 ) {
352                                                 global $wgUser;
353                                                 $this->trigger = sprintf( "%dx %s at [[%s]]: %s",
354                                                         $numHits,
355                                                         $regex,
356                                                         $wgUser->getName(),
357                                                         $title->getPrefixedText(),
358                                                         implode( ", ", $addedMatches ) );
359                                                 $this->action = 'edit';
360                                                 return true;
361                                         }
362                                 }
363                         }
364                 }
365
366                 return false;
367         }
368
369         /**
370          * Filter callback function for URL whitelisting
371          * @param string url to check
372          * @return bool true if unknown, false if whitelisted
373          * @access private
374          */
375         function filterLink( $url ) {
376                 global $wgCaptchaWhitelist;
377                 $source = wfMsgForContent( 'captcha-addurl-whitelist' );
378
379                 $whitelist = wfEmptyMsg( 'captcha-addurl-whitelist', $source ) 
380                         ? false
381                         : $this->buildRegexes( explode( "\n", $source ) );
382
383                 $cwl = $wgCaptchaWhitelist !== false ? preg_match( $wgCaptchaWhitelist, $url ) : false;
384                 $wl  = $whitelist          !== false ? preg_match( $whitelist, $url )          : false;
385
386                 return !( $cwl || $wl );
387         }
388
389         /**
390          * Build regex from whitelist
391          * @param string lines from [[MediaWiki:Captcha-addurl-whitelist]]
392          * @return string Regex or bool false if whitelist is empty
393          * @access private
394          */
395         function buildRegexes( $lines ) {
396                 # Code duplicated from the SpamBlacklist extension (r19197)
397
398                 # Strip comments and whitespace, then remove blanks
399                 $lines = array_filter( array_map( 'trim', preg_replace( '/#.*$/', '', $lines ) ) );
400
401                 # No lines, don't make a regex which will match everything
402                 if ( count( $lines ) == 0 ) {
403                         wfDebug( "No lines\n" );
404                         return false;
405                 } else {
406                         # Make regex
407                         # It's faster using the S modifier even though it will usually only be run once
408                         //$regex = 'http://+[a-z0-9_\-.]*(' . implode( '|', $lines ) . ')';
409                         //return '/' . str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $regex) ) . '/Si';
410                         $regexes = '';
411                         $regexStart = '/^https?:\/\/+[a-z0-9_\-.]*(';
412                         $regexEnd = ')/Si';
413                         $regexMax = 4096;
414                         $build = false;
415                         foreach( $lines as $line ) {
416                                 // FIXME: not very robust size check, but should work. :)
417                                 if( $build === false ) {
418                                         $build = $line;
419                                 } elseif( strlen( $build ) + strlen( $line ) > $regexMax ) {
420                                         $regexes .= $regexStart .
421                                                 str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $build) ) .
422                                                 $regexEnd;
423                                         $build = $line;
424                                 } else {
425                                         $build .= '|' . $line;
426                                 }
427                         }
428                         if( $build !== false ) {
429                                 $regexes .= $regexStart .
430                                         str_replace( '/', '\/', preg_replace('|\\\*/|', '/', $build) ) .
431                                         $regexEnd;
432                         }
433                         return $regexes;
434                 }
435         }
436
437         /**
438          * Load external links from the externallinks table
439          */
440         function getLinksFromTracker( $title ) {
441                 $dbr =& wfGetDB( DB_SLAVE );
442                 $id = $title->getArticleId(); // should be zero queries
443                 $res = $dbr->select( 'externallinks', array( 'el_to' ), 
444                         array( 'el_from' => $id ), __METHOD__ );
445                 $links = array();
446                 while ( $row = $dbr->fetchObject( $res ) ) {
447                         $links[] = $row->el_to;
448                 }
449                 return $links;
450         }
451         
452         /**
453          * Backend function for confirmEdit() and confirmEditAPI()
454          * @return bool false if the CAPTCHA is rejected, true otherwise
455          */
456         private function doConfirmEdit( &$editPage, $newtext, $section, $merged = false ) {
457                 if( $this->shouldCheck( $editPage, $newtext, $section, $merged ) ) {
458                         if( $this->passCaptcha() ) {
459                                 return true;
460                         } else {
461                                 return false;
462                         }
463                 } else {
464                         wfDebug( "ConfirmEdit: no need to show captcha.\n" );
465                         return true;
466                 }
467         }
468
469         /**
470          * The main callback run on edit attempts.
471          * @param EditPage $editPage
472          * @param string $newtext
473          * @param string $section
474          * @param bool $merged
475          * @return bool true to continue saving, false to abort and show a captcha form
476          */
477         function confirmEdit( &$editPage, $newtext, $section, $merged = false ) {
478                 global $wgTitle;
479                 if( is_null( $wgTitle ) ) {
480                         # API mode
481                         # The CAPTCHA was already checked and approved 
482                         return true;
483                 }
484                 if( !$this->doConfirmEdit( $editPage, $newtext, $section, $merged ) ) {
485                         $editPage->showEditForm( array( &$this, 'editCallback' ) );
486                         return false;
487                 }
488                 return true;
489         }
490
491         /**
492          * A more efficient edit filter callback based on the text after section merging
493          * @param EditPage $editPage
494          * @param string $newtext
495          */
496         function confirmEditMerged( &$editPage, $newtext ) {
497                 return $this->confirmEdit( $editPage, $newtext, false, true );
498         }
499         
500         
501         function confirmEditAPI( &$editPage, $newtext, &$resultArr) {
502                 if( !$this->doConfirmEdit( $editPage, $newtext, false, false ) ) {
503                         $this->addCaptchaAPI($resultArr);
504                         return false;
505                 }
506                 return true;
507         }
508
509         /**
510          * Hook for user creation form submissions.
511          * @param User $u
512          * @param string $message
513          * @return bool true to continue, false to abort user creation
514          */
515         function confirmUserCreate( $u, &$message ) {
516                 global $wgCaptchaTriggers;
517                 if( $wgCaptchaTriggers['createaccount'] ) {
518                         $this->trigger = "new account '" . $u->getName() . "'";
519                         if( !$this->passCaptcha() ) {
520                                 $message = wfMsg( 'captcha-createaccount-fail' );
521                                 return false;
522                         }
523                 }
524                 return true;
525         }
526         
527         /**
528          * Hook for user login form submissions.
529          * @param User $u
530          * @param string $message
531          * @return bool true to continue, false to abort user creation
532          */
533         function confirmUserLogin( $u, $pass, &$retval ) {
534                 if( $this->isBadLoginTriggered() ) {
535                         $this->trigger = "post-badlogin login '" . $u->getName() . "'";
536                         if( !$this->passCaptcha() ) {
537                                 $message = wfMsg( 'captcha-badlogin-fail' );
538                                 // Emulate a bad-password return to confuse the shit out of attackers
539                                 $retval = LoginForm::WRONG_PASS;
540                                 return false;
541                         }
542                 }
543                 return true;
544         }
545
546         /**
547          * Given a required captcha run, test form input for correct
548          * input on the open session.
549          * @return bool if passed, false if failed or new session
550          */
551         function passCaptcha() {
552                 $info = $this->retrieveCaptcha();
553                 if( $info ) {
554                         global $wgRequest;
555                         if( $this->keyMatch( $wgRequest->getVal('wpCaptchaWord'), $info ) ) {
556                                 $this->log( "passed" );
557                                 $this->clearCaptcha( $info );
558                                 return true;
559                         } else {
560                                 $this->clearCaptcha( $info );
561                                 $this->log( "bad form input" );
562                                 return false;
563                         }
564                 } else {
565                         $this->log( "new captcha session" );
566                         return false;
567                 }
568         }
569
570         /**
571          * Log the status and any triggering info for debugging or statistics
572          * @param string $message
573          */
574         function log( $message ) {
575                 wfDebugLog( 'captcha', 'ConfirmEdit: ' . $message . '; ' .  $this->trigger );
576         }
577
578         /**
579          * Generate a captcha session ID and save the info in PHP's session storage.
580          * (Requires the user to have cookies enabled to get through the captcha.)
581          *
582          * A random ID is used so legit users can make edits in multiple tabs or
583          * windows without being unnecessarily hobbled by a serial order requirement.
584          * Pass the returned id value into the edit form as wpCaptchaId.
585          *
586          * @param array $info data to store
587          * @return string captcha ID key
588          */
589         function storeCaptcha( $info ) {
590                 if( !isset( $info['index'] ) ) {
591                         // Assign random index if we're not udpating
592                         $info['index'] = strval( mt_rand() );
593                 }
594                 $this->storage->store( $info['index'], $info );
595                 return $info['index'];
596         }
597
598         /**
599          * Fetch this session's captcha info.
600          * @return mixed array of info, or false if missing
601          */
602         function retrieveCaptcha() {
603                 global $wgRequest;
604                 $index = $wgRequest->getVal( 'wpCaptchaId' );
605                 return $this->storage->retrieve( $index );
606         }
607
608         /**
609          * Clear out existing captcha info from the session, to ensure
610          * it can't be reused.
611          */
612         function clearCaptcha( $info ) {
613                 $this->storage->clear( $info['index'] );
614         }
615
616         /**
617          * Retrieve the current version of the page or section being edited...
618          * @param EditPage $editPage
619          * @param string $section
620          * @return string
621          * @access private
622          */
623         function loadText( $editPage, $section ) {
624                 $rev = Revision::newFromTitle( $editPage->mTitle );
625                 if( is_null( $rev ) ) {
626                         return "";
627                 } else {
628                         $text = $rev->getText();
629                         if( $section != '' ) {
630                                 return Article::getSection( $text, $section );
631                         } else {
632                                 return $text;
633                         }
634                 }
635         }
636
637         /**
638          * Extract a list of all recognized HTTP links in the text.
639          * @param string $text
640          * @return array of strings
641          */
642         function findLinks( $text ) {
643                 global $wgParser, $wgTitle, $wgUser;
644
645                 $options = new ParserOptions();
646                 $text = $wgParser->preSaveTransform( $text, $wgTitle, $wgUser, $options );
647                 $out = $wgParser->parse( $text, $wgTitle, $options );
648
649                 return array_keys( $out->getExternalLinks() );
650         }
651
652         /**
653          * Show a page explaining what this wacky thing is.
654          */
655         function showHelp() {
656                 global $wgOut, $ceAllowConfirmedEmail;
657                 $wgOut->setPageTitle( wfMsg( 'captchahelp-title' ) );
658                 $wgOut->addWikiText( wfMsg( 'captchahelp-text' ) );
659                 if ( $this->storage->cookiesNeeded() ) {
660                         $wgOut->addWikiText( wfMsg( 'captchahelp-cookies-needed' ) );
661                 }
662         }
663
664 }
665
666 class CaptchaSessionStore {
667         function store( $index, $info ) {
668                 $_SESSION['captcha' . $info['index']] = $info;
669         }
670         
671         function retrieve( $index ) {
672                 if( isset( $_SESSION['captcha' . $index] ) ) {
673                         return $_SESSION['captcha' . $index];
674                 } else {
675                         return false;
676                 }
677         }
678         
679         function clear( $index ) {
680                 unset( $_SESSION['captcha' . $index] );
681         }
682
683         function cookiesNeeded() {
684                 return true;
685         }
686 }
687
688 class CaptchaCacheStore {
689         function store( $index, $info ) {
690                 global $wgMemc, $wgCaptchaSessionExpiration;
691                 $wgMemc->set( wfMemcKey( 'captcha', $index ), $info,
692                         $wgCaptchaSessionExpiration );
693         }
694
695         function retrieve( $index ) {
696                 global $wgMemc;
697                 $info = $wgMemc->get( wfMemcKey( 'captcha', $index ) );
698                 if( $info ) {
699                         return $info;
700                 } else {
701                         return false;
702                 }
703         }
704         
705         function clear( $index ) {
706                 global $wgMemc;
707                 $wgMemc->delete( wfMemcKey( 'captcha', $index ) );
708         }
709
710         function cookiesNeeded() {
711                 return false;
712         }
713 }
714