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