fu: Close-up of Fu, bringing a scoop of water to her mouth (Default)
fu ([personal profile] fu) wrote in [site community profile] changelog2012-02-18 05:27 am

[dw-free] offer text captcha as an alternative to reCAPTCHA

[commit: http://hg.dwscoalition.org/dw-free/rev/6888252ac285]

http://bugs.dwscoalition.org/show_bug.cgi?id=3227

Implement textCAPTCHA: Refactors the DW::Captcha module to be able to
support multiple captcha types / implementations. Adds a setting to allow
the captcha type to be set on a per-journal basis. (default will be
textcaptcha rather than the graphical recaptcha)

Patch by [personal profile] fu.

Files modified:
  • bin/upgrading/en.dat
  • bin/upgrading/proplists.dat
  • cgi-bin/DW/Captcha.pm
  • cgi-bin/DW/Captcha/reCAPTCHA.pm
  • cgi-bin/DW/Captcha/textCAPTCHA.pm
  • cgi-bin/DW/Controller/RPC/TextCAPTCHA.pm
  • cgi-bin/DW/Setting/Captcha.pm
  • cgi-bin/LJ/Global/Defaults.pm
  • cgi-bin/LJ/Talk.pm
  • cgi-bin/LJ/User.pm
  • cgi-bin/LJ/Widget/SubmitRequest.pm
  • doc/config-local.pl.txt
  • doc/config-private.pl.txt
  • etc/config.pl
  • htdocs/create.bml
  • htdocs/js/dw/dw-core.js
  • htdocs/lostinfo.bml
  • htdocs/manage/settings/index.bml
  • htdocs/register.bml
  • htdocs/stc/textcaptcha.css
  • htdocs/support/submit.bml
  • t/captcha-textcaptcha.t
  • t/captcha.t
  • views/textcaptcha-response.tt
  • views/textcaptcha-response.tt.text
  • views/textcaptcha.tt
  • views/textcaptcha.tt.text
--------------------------------------------------------------------------------
diff -r 40433367c052 -r 6888252ac285 bin/upgrading/en.dat
--- a/bin/upgrading/en.dat	Wed Feb 15 06:01:15 2012 +0800
+++ b/bin/upgrading/en.dat	Sat Feb 18 13:26:01 2012 +0800
@@ -33,6 +33,8 @@
 
 captcha.invalid=Incorrect response in the antispam field. Please try again.
 
+captcha.loading=Loading anti-spam test...
+
 captcha.title=Please fill out the CAPTCHA as an anti-spam measure
 
 cleanhtml.error.markup=( <a [[aopts]]>Error: Irreparable invalid markup in entry. Raw contents behind the cut.</a> )
@@ -2436,6 +2438,14 @@
 
 setting.birthdaydisplay.question=Birthday display options:
 
+setting.captcha.label=Anti-Spam Type
+
+setting.captcha.option=Commenters will see
+
+setting.captcha.option.select.image=graphical/image anti-spam tests
+
+setting.captcha.option.select.text=text-based anti-spam tests
+
 setting.commentcaptcha.label=Anti-Spam
 
 setting.commentcaptcha.option=Show CAPTCHA to
diff -r 40433367c052 -r 6888252ac285 bin/upgrading/proplists.dat
--- a/bin/upgrading/proplists.dat	Wed Feb 15 06:01:15 2012 +0800
+++ b/bin/upgrading/proplists.dat	Sat Feb 18 13:26:01 2012 +0800
@@ -54,6 +54,14 @@
   multihomed: 0
   prettyname: Language to browse with
 
+userproplist.captcha:
+  cldversion: 4
+  datatype: char
+  des: CAPTCHA type to show in the user's journal
+  indexed: 0
+  multihomed: 0
+  prettyname: CAPTCHA type
+
 userproplist.city:
   cldversion: 4
   datatype: char
diff -r 40433367c052 -r 6888252ac285 cgi-bin/DW/Captcha.pm
--- a/cgi-bin/DW/Captcha.pm	Wed Feb 15 06:01:15 2012 +0800
+++ b/cgi-bin/DW/Captcha.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -7,7 +7,7 @@
 # Authors:
 #      Afuna <coder.dw@afunamatata.com>
 #
-# Copyright (c) 2010 by Dreamwidth Studios, LLC.
+# Copyright (c) 2010-2012 by Dreamwidth Studios, LLC.
 #
 # This program is free software; you may redistribute it and/or modify it under
 # the same terms as Perl itself.  For a copy of the license, please reference
@@ -20,89 +20,183 @@
 
 =head1 SYNOPSIS
 
+Here's the simplest method:
+
+    # print out the captcha form fields on a particular page
+    my $captcha = DW::Captcha->new( $page );
+    if ( $captcha->enabled ) {
+        $captcha->print;
+    }
+
+    # elsewhere, process the post
+    if ( $r->did_post ) {
+        my $captcha = DW::Captcha->new( $page, %{$r->post_args} );
+
+        my $captcha_error;
+        push @errors, $captcha_error unless $captcha->validate( err_ref => \$captcha_error );
+    }
+
+
+When using in conjunction with LJ::Widget subclasses, you can just specify the form field names and let the widget handle it:
+
+    LJ::Widget->use_specific_form_fields( post => \%POST, widget => "...", fields => [ DW::Captcha->form_fields ] )
+        if DW::Captcha->enabled( 'create' );
 =cut
 
 use strict;
-use warnings;
 package DW::Captcha;
 
-# at some point we may replace this, or try to make this implementation more flexible
-# right now, let's just go with this
+use LJ::ModuleLoader;
 
-BEGIN {
-    my $rv = eval <<USE;
-use Captcha::reCAPTCHA;
-1;
-USE
-    warn "NOTE: Captcha::reCAPTCHA was not found.\n"
-        unless $rv;
+my @CLASSES = LJ::ModuleLoader->module_subclasses( "DW::Captcha" );
 
-    our $MODULES_INSTALLED = $rv;
+my %impl2class;
+foreach my $class ( @CLASSES ) {
+    eval "use $class";
+    die "Error loading class '$class': $@" if $@;
+    $impl2class{lc $class->name} = $class;
 }
 
 # class methods
+=head1 API
+
+=head2 C<< DW::Captcha->new( $implementation, $page, %opts ) >>
+
+Arguments:
+
+=over
+
+=item page - the page we're going to display this CAPTCHA on
+
+=item implementation - which CAPTCHA implementation we'd like to use
+
+=item a hash of additional options, including the request/response from a form post
+
+=back
+
+=cut
+
 sub new {
-    my ( $class, $type, %opts ) = @_;
+    my ( $class, $page, %opts ) = @_;
+
+    # yes, I really do want to do this rather than $impl{...||$LJ::DEFAULT_CAPTCHA...}
+    # we want to make certain that someone can't force all captchas off
+    # by asking for an invalid captcha type
+    my $impl = $LJ::CAPTCHA_TYPES{delete $opts{want} || ""} || "";
+    my $subclass = $impl2class{$impl};
+    $subclass = $impl2class{$LJ::CAPTCHA_TYPES{$LJ::DEFAULT_CAPTCHA_TYPE}}
+        unless $subclass && $subclass->site_enabled;
 
     my $self = bless {
-        type => $type,
-    }, $class;
+        page => $page,
+    }, $subclass;
 
-    $self->init_opts( %opts );
+    $self->_init_opts( %opts );
 
     return $self;
 }
 
-sub form_fields { qw( recaptcha_response_field recaptcha_challenge_field ) }
-sub public_key  { LJ::conf_test( $LJ::RECAPTCHA{public_key} ) }
-sub private_key { LJ::conf_test( $LJ::RECAPTCHA{private_key} ) }
+# must be implemented by subclasses
+=head2 C<< $class->name >>
 
-sub site_enabled {
-    return 0 unless $DW::Captcha::MODULES_INSTALLED;
-    return LJ::is_enabled( 'recaptcha' ) && $LJ::RECAPTCHA{public_key} && $LJ::RECAPTCHA{private_key};
-}
+The name used to refer to this CAPTCHA implementation.
+
+=cut
+
+sub name { return ""; }
+
 
 # object methods
+
+=head2 C<< $captcha->form_fields >>
+
+Returns a list of the form fields expected by the CAPTCHA implementation.
+
+=head2 C<< $captcha->site_enabled >>
+
+Whether CAPTCHA is enabled site-wide. (Specific pages may have CAPTCHA disabled)
+
+=head2 C<< $captcha->print >>
+
+Print the CAPTCHA form fields.
+
+=head2 C<< $captcha->validate( %opts ) >>
+
+Return whether the response for this CAPTCHA was valid.
+
+Arguments:
+
+=over
+
+=item opts - a hash of additional options, including the request/response from a form post
+and an error reference (err_ref) which may contain additional information in case
+the validation failed
+
+=back
+
+=head2 C<< $captcha->enabled( $page ) >>
+
+Whether this CAPTCHA implementation is enabled on this particular page
+(or sitewide if this captcha instance isn't tied to a specific page)
+
+Arguments:
+
+=over
+
+=item page - Optional. A specific page to check
+
+=back
+
+=head2 C<< $captcha->page >>
+
+Return the page that this CAPTCHA instance is going to be used with
+
+=head2 C<< $captcha->challenge >>
+
+Challenge text, provided by the CAPTCHA implementation
+
+=head2 C<< $captcha->response >>
+
+User-provided response text
+
+=cut
+
+# must be implemented by subclasses
+sub form_fields { qw() }
+
+sub site_enabled { return LJ::is_enabled( 'captcha' ) && $_[0]->_implementation_enabled ? 1 : 0 }
+
+# must be implemented by subclasses
+sub _implementation_enabled { return 1; }
+
+
 sub print {
     my $self = $_[0];
     return "" unless $self->enabled;
 
-    my $captcha = Captcha::reCAPTCHA->new;
-    my $ret = $captcha->get_options_setter( { theme => 'white' } );
-
-    $ret .= "<div class='captcha'>";
-
-    $ret .= $captcha->get_html(
-        public_key(),               # public key
-        '',                         # error (optional)
-        $LJ::IS_SSL                 # page uses ssl
-    );
-
-    $ret .= "<p>" . BML::ml( 'captcha.accessibility.contact', { email => $LJ::SUPPORT_EMAIL } ) . "</p>";
+    my $ret = "<div class='captcha'>";
+    $ret .= $self->_print;
+    $ret .= "<p style='clear:both'>" . BML::ml( 'captcha.accessibility.contact', { email => $LJ::SUPPORT_EMAIL } ) . "</p>";
     $ret .= "</div>";
 
     return $ret;
 }
 
-sub validate { 
+# must be implemented by subclasses
+sub _print { return ""; }
+
+sub validate {
     my ( $self, %opts ) = @_;
 
     # if disabled, then it's always valid to allow the post to go through
     return 1 unless $self->enabled;
 
-    $self->init_opts( %opts );
+    $self->_init_opts( %opts );
 
     my $err_ref = $opts{err_ref};
-    my $result;
 
     if ( $self->challenge ) {
-        my $captcha = Captcha::reCAPTCHA->new;
-        $result = $captcha->check_answer(
-            private_key(), $ENV{REMOTE_ADDR},
-            $self->challenge, $self->response
-        );
-
-       return 1 if $result->{is_valid} eq '1';
+        return 1 if $self->_validate;
     }
 
     $$err_ref = LJ::Lang::ml( 'captcha.invalid' );
@@ -110,25 +204,30 @@
     return 0;
 }
 
-# enabled can be used as either a class or an object method
+# must be implemented by subclasses
+sub _validate { return 0; }
+
 sub enabled {
+    my $page;
+    $page = $_[0]->page if ref $_[0];
+    $page ||= $_[1];
 
-    my $type = ref $_[0] ? $_[0]->type : $_[1];
-
-    return $type
-        ? site_enabled() && $LJ::CAPTCHA_FOR{$type}
-        : site_enabled();
+    return $page
+        ? $_[0]->site_enabled() && $LJ::CAPTCHA_FOR{$page}
+        : $_[0]->site_enabled();
 }
 
-sub init_opts {
+# internal method. Used to initialize the challenge and response fields
+# must be implemented by subclasses
+sub _init_opts {
     my ( $self, %opts ) = @_;
 
-    $self->{challenge} ||= $opts{recaptcha_challenge_field};
-    $self->{response} ||= $opts{recaptcha_response_field};
+    # do something
 }
 
-sub type      { return $_[0]->{type} }
+sub page      { return $_[0]->{page} }
 sub challenge { return $_[0]->{challenge} }
 sub response  { return $_[0]->{response} }
 
+
 1;
diff -r 40433367c052 -r 6888252ac285 cgi-bin/DW/Captcha/reCAPTCHA.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/Captcha/reCAPTCHA.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,88 @@
+#!/usr/bin/perl
+#
+# DW::Captcha::reCAPTCHA
+#
+# This module handles integration with the reCAPTCHA service
+#
+# Authors:
+#      Afuna <coder.dw@afunamatata.com>
+#
+# Copyright (c) 2012 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself.  For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+
+=head1 NAME
+
+DW::Captcha::reCAPTCHA - This module handles integration with the reCAPTCHA service
+
+=head1 SYNOPSIS
+
+=cut
+
+use strict;
+
+package DW::Captcha::reCAPTCHA;
+use base 'DW::Captcha';
+
+BEGIN {
+    my $rv = eval <<USE;
+use Captcha::reCAPTCHA;
+1;
+USE
+    warn "NOTE: Captcha::reCAPTCHA was not found.\n"
+        unless $rv;
+
+    our $MODULES_INSTALLED = $rv;
+}
+
+
+# implemented as overrides for the base class
+
+
+# class methods
+sub name { return "recaptcha" }
+
+# object methods
+sub form_fields { qw( recaptcha_response_field recaptcha_challenge_field ) }
+sub _implementation_enabled {
+    return 0 unless $DW::Captcha::reCAPTCHA::MODULES_INSTALLED;
+    return LJ::is_enabled( 'captcha', 'recaptcha' ) && _public_key() && _private_key() ? 1 : 0;
+}
+
+sub _print {
+    my $captcha = Captcha::reCAPTCHA->new;
+    return $captcha->get_options_setter( { theme => 'white' } ) .
+    $captcha->get_html(
+        _public_key(),              # public key
+        '',                         # error (optional)
+        $LJ::IS_SSL                 # page uses ssl
+    );
+}
+
+sub _validate {
+    my $self = $_[0];
+
+    my $captcha = Captcha::reCAPTCHA->new;
+    my $result = $captcha->check_answer(
+        _private_key(), $ENV{REMOTE_ADDR},
+        $self->challenge, $self->response
+    );
+
+    return 1 if $result->{is_valid} eq '1';
+}
+
+sub _init_opts {
+    my ( $self, %opts ) = @_;
+
+    $self->{challenge} ||= $opts{recaptcha_challenge_field};
+    $self->{response} ||= $opts{recaptcha_response_field};
+}
+
+# recaptcha-specific methods
+sub _public_key  { LJ::conf_test( $LJ::RECAPTCHA{public_key} ) }
+sub _private_key { LJ::conf_test( $LJ::RECAPTCHA{private_key} ) }
+
+1;
diff -r 40433367c052 -r 6888252ac285 cgi-bin/DW/Captcha/textCAPTCHA.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/Captcha/textCAPTCHA.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,237 @@
+#!/usr/bin/perl
+#
+# DW::Captcha::textCAPTCHA
+#
+# This module handles integration with the textCAPTCHA service
+#
+# Authors:
+#      Afuna <coder.dw@afunamatata.com>
+#
+# Copyright (c) 2012 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself.  For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+
+=head1 NAME
+
+DW::Captcha::textCAPTCHA - This module handles integration with the textCAPTCHA service
+
+=head1 SYNOPSIS
+
+=cut
+
+use strict;
+
+package DW::Captcha::textCAPTCHA;
+use base 'DW::Captcha';
+
+use XML::Simple;
+use Digest::MD5 ();
+
+
+# implemented as overrides for the base class
+
+
+# class methods
+sub name { return "textcaptcha" }
+
+# object methods
+sub form_fields { qw( textcaptcha_challenge textcaptcha_response textcaptcha_response_noscript textcaptcha_chalauth ) }
+sub _implementation_enabled {
+    return LJ::is_enabled( 'captcha', 'textcaptcha' ) && _api_key() ? 1 : 0;
+}
+
+# this prints out the js/iframe to load the captcha (but not the captcha itself)
+sub _print {
+    my $self = $_[0];
+
+    # form_auth remains the same throughout the lifetime of the request
+    # so we can just call this here instead of passing it in from the external form
+    my $auth = LJ::form_auth( 1 );
+
+    # we can't use need_res, alas, because we're printing this inline
+    # after the <head> has already been printed
+    # FIXME: remove check when we get rid of the old library
+    my $loading_text = LJ::Lang::ml( "captcha.loading" );
+    my $captcha_load = $LJ::ACTIVE_RES_GROUP && $LJ::ACTIVE_RES_GROUP eq "jquery"
+        ? qq!
+<script type="text/javascript">
+jQuery(function(jq){
+    jq("#textcaptcha_container").html("$loading_text")
+        .load(jq.endpoint("captcha") + "/$auth");
+});
+</script>
+        ! : qq!
+<script type="text/javascript">
+    \$("textcaptcha_container").innerHTML = "$loading_text";
+    HTTPReq.getJSON({
+        "url": LiveJournal.getAjaxUrl("captcha") + "/$auth.json",
+        "method": "GET",
+        "onError": LiveJournal.ajaxError,
+        "onData": function(data) {\$("textcaptcha_container").innerHTML = data.captcha}
+    })
+</script>
+    !;
+
+    my $response_label = LJ::Lang::ml( "/textcaptcha-response.tt.response.user.label" );
+
+    # putting it in noscript so that we don't load it the page unnecessarily if we actually have JS
+    return qq{
+<div id="textcaptcha_container" aria-live="assertive" style="line-height: 1.2em">
+    $captcha_load
+<noscript><iframe src="$LJ::SITEROOT/captcha/text/$auth" style="width:100%;height:4em" id="textcaptcha_fallback"></iframe>
+<label for="textcaptcha_response_noscript">$response_label</label> <input type="text" maxlength="255" autocomplete="off" value="" name="textcaptcha_response_noscript" class="text" id="textcaptcha_response_noscript" size="50" />
+</noscript>
+</div>};
+}
+
+sub _validate {
+    my $self = $_[0];
+    return DW::Captcha::textCAPTCHA::Logic::check_answer( $self->challenge, $self->response, $self->form_auth, $self->captcha_auth );
+}
+
+sub _init_opts {
+    my ( $self, %opts ) = @_;
+
+    # rather than having a lot of ifs/elses here to extract multiple keys
+    # when we're pulling via BML vs via a controller, etc
+    # let's just pull directly from the request
+    my $r = DW::Request->get;
+    my $post_args = $r->post_args if $r;
+
+    if ( $post_args ) {
+        if ( my $response_noscript = $post_args->{textcaptcha_response_noscript} ) {
+            my %parsed = DW::Captcha::textCAPTCHA::Logic::from_form_string( $response_noscript );
+            $self->{$_} ||= $parsed{$_} foreach qw( challenge response form_auth captcha_auth );
+        } else {
+            # allow multiple values
+            $self->{challenge} ||= [ $post_args->get_all( "textcaptcha_challenge" ) ];
+
+            # just allow the user to submit one
+            $self->{response} ||= $post_args->{textcaptcha_response};
+
+            # assume we need the form auth
+            $self->{form_auth} ||= $post_args->{lj_form_auth};
+
+            $self->{captcha_auth} ||= $post_args->{textcaptcha_chalauth};
+        }
+    }
+}
+
+=head1 C<< textCAPTCHA-specific methods >>
+
+=cut
+
+# textcaptcha-specific methods
+sub _api_key { LJ::conf_test( $LJ::TEXTCAPTCHA{api_key} ) }
+
+=head2 C<< $captcha->form_auth >>
+
+Generic form auth. Ties this captcha to a specific form instance.
+
+=cut
+
+=head2 C<< $captcha->captcha_auth >>
+
+Additional auth for this captcha. Enforces time limit and single use.
+
+=cut
+
+sub form_auth { return $_[0]->{form_auth} }
+sub captcha_auth { return $_[0]->{captcha_auth} }
+
+package DW::Captcha::textCAPTCHA::Logic;
+
+# this is an internal class which shouldn't be called directly by anything else
+# (except maybe tests)
+
+sub fetch {
+    my ( $class ) = $_[0];
+
+    my $ua = LJ::get_useragent( role => 'textcaptcha', timeout => $LJ::TEXTCAPTCHA{timeout} );
+    $ua->agent("$LJ::SITENAME ($LJ::ADMIN_EMAIL; captcha request)");
+    my $res = $ua->get( "http://api.textcaptcha.com/" . DW::Captcha::textCAPTCHA::_api_key() );
+    return $res && $res->is_success ? $res->content : "";
+}
+
+# arguments:
+# * xml string containing the captcha question as a plain string
+#   and answer, or answers, as an MD5 hash
+# * the form auth which we can use to tie this captcha to a particular instance
+
+# returns a hashref containing data suitable for use within the form:
+# * the question to display
+# * answers (salted)
+# * captcha auth
+sub form_data {
+    my ( $captcha_data_string, $auth ) = @_;
+    my $captcha = eval { XML::Simple::XMLin( $captcha_data_string, ForceArray => [ 'answer' ] ); };
+
+    # get the timestamp
+    my $secret = LJ::get_secret( (split( /:/, $auth ))[1] );
+    my @salted_answers = map { Digest::MD5::md5_hex( $auth . $secret . $_ ) } @{$captcha->{answer}};
+
+    return {
+        question => $captcha->{question},
+        answers => \@salted_answers,
+        chal    => LJ::challenge_generate( 900 ),  # 15 minute token
+    };
+}
+
+# arguments:
+# * valid responses to the captcha
+# * the user's response
+# * form auth
+# * captcha auth
+sub check_answer {
+    my ( $form_responses, $user_response, $form_auth, $captcha_auth ) = @_;
+
+    # all forms we use captcha with should have had a corresponding lj_form_auth
+    # but just in case we miss a spot (though we really shouldn't) let's cut this short
+    # also cut short if we don't provide the captcha-specific auth
+    return 0 unless $form_auth && $captcha_auth;
+
+    my $chal_opts = {};
+    return 0 unless LJ::challenge_check( $captcha_auth, $chal_opts );
+
+    my $secret = LJ::get_secret( ( split( /:/, $form_auth ))[1] );
+
+    my $user_answer = Digest::MD5::md5_hex( LJ::trim( lc $user_response ) );
+    my $check_answer = Digest::MD5::md5_hex( $form_auth . $secret . $user_answer );
+
+    foreach ( @$form_responses ) {
+        return 1 if $_ eq $check_answer;
+    }
+
+    return 0;
+}
+
+# concatenate all relevant values into one string
+sub to_form_string {
+    my $self = $_[0];
+
+    return join( "::",
+        (   $self->form_auth,
+            $self->captcha_auth,
+            $self->response,
+            join( "::", @{$self->challenge||[]} )
+        )
+    );
+}
+
+# return a hash
+sub from_form_string {
+    my ( $string ) = @_;
+
+    my ( $form_auth, $captcha_auth, $response, @challenges ) = split "::", $string;
+    return (
+        form_auth       => $form_auth,
+        captcha_auth    => $captcha_auth,
+        response        => $response,
+        challenge       => \@challenges
+    );
+}
+
+1;
diff -r 40433367c052 -r 6888252ac285 cgi-bin/DW/Controller/RPC/TextCAPTCHA.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/Controller/RPC/TextCAPTCHA.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,73 @@
+#!/usr/bin/perl
+#
+# DW::Controller::TextCAPTCHA
+#
+# AJAX endpoint that returns a textCAPTCHA instance
+# May also be loaded on a page
+#
+# Author:
+#      Afuna <coder.dw@afunamatata.com>
+#
+# Copyright (c) 2012 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself. For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+
+package DW::Controller::RPC::TextCAPTCHA;
+
+use strict;
+
+use DW::Routing;
+use JSON;
+use DW::Captcha::textCAPTCHA;
+
+DW::Routing->register_regex( "/__rpc_captcha/(.*)", \&captcha_handler, app => 1, user => 1, format => 'html', formats => [qw( html json )] );
+DW::Routing->register_regex( "/captcha/text/(.*)", \&iframe_captcha_handler, app => 1, format => 'html' );
+
+# loaded inline into the page using JS
+sub captcha_handler {
+    my ( $call_opts, $auth ) = @_;
+
+    my $from_textcaptcha = DW::Captcha::textCAPTCHA::Logic->fetch;
+    my ( $captcha ) = DW::Captcha::textCAPTCHA::Logic::form_data( $from_textcaptcha,  $auth );
+
+    if ( $call_opts->format eq "json" ) {
+        # json format is for the old JS library
+        my $captcha_html = DW::Template->template_string( 'textcaptcha.tt', { captcha => $captcha }, { fragment => 1 } );
+
+        my $r = DW::Request->get;
+        $r->print( objToJson( { captcha => $captcha_html } ) );
+        return $r->OK;
+    } else {
+        return DW::Template->render_template( 'textcaptcha.tt', { captcha => $captcha }, { fragment => 1 } );
+    }
+}
+
+# fallback for the no-js case
+sub iframe_captcha_handler {
+    my ( $call_opts, $auth ) = @_;
+
+    my $error;
+    my $r = DW::Request->get;
+    if ( $r->did_post ) {
+        my $captcha_object = DW::Captcha->new( undef, %{$r->post_args} );
+
+        # we don't check whether this is true or not (we'd end up expiring the captcha's auth if we did)
+        return DW::Template->render_template( 'textcaptcha-response.tt', { response => DW::Captcha::textCAPTCHA::Logic::to_form_string( $captcha_object ) }, { fragment => 1 } );
+    }
+
+    my $from_textcaptcha = DW::Captcha::textCAPTCHA::Logic->fetch;
+    my ( $captcha ) = DW::Captcha::textCAPTCHA::Logic::form_data( $from_textcaptcha,  $auth );
+
+    return DW::Template->render_template( 'textcaptcha.tt', {
+        handle_submit   => 1,
+
+        captcha         => $captcha,
+        form_auth       => $auth,
+        error           => $error
+    }, { fragment => 1 } );
+}
+
+1;
diff -r 40433367c052 -r 6888252ac285 cgi-bin/DW/Setting/Captcha.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/Setting/Captcha.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,71 @@
+#!/usr/bin/perl
+#
+# DW::Setting::Captcha
+#
+# LJ::Setting module for choosing the captcha type to display on this journal
+#
+# Authors:
+#      Afuna <coder.dw@afunamatata.com>
+#
+# Copyright (c) 2012 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself.  For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+
+package DW::Setting::Captcha;
+use base 'LJ::Setting';
+use strict;
+
+use DW::Captcha;
+
+sub should_render {
+    my ( $class, $u ) = @_;
+    return $u->is_identity ? 0 : 1;
+}
+
+sub label {
+    my $class = shift;
+    return $class->ml( 'setting.captcha.label' );
+}
+
+sub option {
+    my ( $class, $u, $errs, $args ) = @_;
+    my $key = $class->pkgkey;
+
+    my $captcha_type = $class->get_arg( $args, "captcha" ) || $u->captcha_type;
+
+    my @opts = (
+        "T" => $class->ml( "setting.captcha.option.select.text" ),
+        "I" => $class->ml( "setting.captcha.option.select.image" ),
+    );
+
+    my $ret;
+    $ret .= "<label for='${key}captcha'>";
+    $ret .= $class->ml( 'setting.captcha.option' );
+    $ret .= "</label> ";
+
+    $ret .= LJ::html_select( { name => "${key}captcha",
+                               id   => "${key}captcha",
+                               selected => $captcha_type },
+                             @opts );
+
+    my $errdiv = $class->errdiv( $errs, "captcha" );
+    $ret .= "<br />$errdiv" if $errdiv;
+
+    return $ret;
+}
+
+sub save {
+    my ( $class, $u, $args ) = @_;
+
+    my $val = $class->get_arg( $args, "captcha" );
+    $val = undef unless $val =~ /^[IT]$/;
+
+    $u->captcha_type( $val );
+
+    return 1;
+}
+
+1;
diff -r 40433367c052 -r 6888252ac285 cgi-bin/LJ/Global/Defaults.pm
--- a/cgi-bin/LJ/Global/Defaults.pm	Wed Feb 15 06:01:15 2012 +0800
+++ b/cgi-bin/LJ/Global/Defaults.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -358,6 +358,14 @@
         "js/hoverIntent.js"             => "js/hoverIntent.minified.js",
         "js/tooltip.js"                 => "js/tooltip.min.js",
     ) unless defined %LJ::MINIFY;
+
+    # mapping of captcha type to specific desired implementation
+    %CAPTCHA_TYPES = (
+        "T" => "textcaptcha",   # "T" is for text
+        "I" => "recaptcha",     # "I" is for image
+    ) unless defined %CAPTCHA_TYPES;
+    $DEFAULT_CAPTCHA_TYPE ||= "T";
+
 }
 
 
diff -r 40433367c052 -r 6888252ac285 cgi-bin/LJ/Talk.pm
--- a/cgi-bin/LJ/Talk.pm	Wed Feb 15 06:01:15 2012 +0800
+++ b/cgi-bin/LJ/Talk.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -2032,7 +2032,7 @@
 
     # Display captcha challenge if over rate limits.
     if ( $opts->{do_captcha} ) {
-        my $captcha = DW::Captcha->new;
+        my $captcha = DW::Captcha->new( undef, want => $journalu->captcha_type );
         $ret .= $captcha->print;
     }
 
@@ -3665,8 +3665,10 @@
     # unixify line-endings
     $form->{'body'} =~ s/\r\n/\n/g;
 
+    # FIXME: remove when we no longer support BML
+    $form->{textcaptcha_challenge} = [ split /\0/, $form->{textcaptcha_challenge} ];
+
     # now check for UTF-8 correctness, it must hold
-
     return $err->("<?badinput?>") unless LJ::text_in($form);
 
     $init->{unknown8bit} = 0;
@@ -3775,10 +3777,11 @@
     ##
     ## 1. Check rate by remote user and by IP (for anonymous user)
     ##
-    if ( DW::Captcha->enabled( 'anonpost' ) || DW::Captcha->enabled( 'authpost' ) ) {
+    my $captcha = DW::Captcha->new;
+    if ( $captcha->enabled( 'anonpost' ) || $captcha->enabled( 'authpost' ) ) {
         return 1 unless LJ::Talk::Post::check_rate( $commenter, $journal );
     }
-    if ( DW::Captcha->enabled( 'anonpost' ) && $anon_commenter) {
+    if ( $captcha->enabled( 'anonpost' ) && $anon_commenter) {
         return 1 if LJ::sysban_check( 'talk_ip_test', LJ::get_remote_ip() );
     }
 
@@ -3819,8 +3822,8 @@
     ## 4. Global (site) settings
     ## See if they have any tags or URLs in the comment's body
     ##
-    if ( DW::Captcha->enabled( 'comment_html_auth' )
-        || ( DW::Captcha->enabled( 'comment_html_anon' ) && $anon_commenter))
+    if ( $captcha->enabled( 'comment_html_auth' )
+        || ( $captcha->enabled( 'comment_html_anon' ) && $anon_commenter))
     {
         if ($body =~ /<[a-z]/i) {
             # strip white-listed bare tags w/o attributes,
diff -r 40433367c052 -r 6888252ac285 cgi-bin/LJ/User.pm
--- a/cgi-bin/LJ/User.pm	Wed Feb 15 06:01:15 2012 +0800
+++ b/cgi-bin/LJ/User.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -2187,6 +2187,16 @@
     return $_[0]->get_cap( 'viewmailqueue' ) ? 1 : 0;
 }
 
+sub captcha_type {
+    my $u = $_[0];
+
+    if ( defined $_[1] ) {
+        $u->set_prop( captcha => $_[1] );
+    }
+
+    return $_[1] || $u->prop( 'captcha' ) || $LJ::DEFAULT_CAPTCHA_TYPE;
+}
+
 sub clear_prop {
     my ($u, $prop) = @_;
     $u->set_prop($prop, undef);
diff -r 40433367c052 -r 6888252ac285 cgi-bin/LJ/Widget/SubmitRequest.pm
--- a/cgi-bin/LJ/Widget/SubmitRequest.pm	Wed Feb 15 06:01:15 2012 +0800
+++ b/cgi-bin/LJ/Widget/SubmitRequest.pm	Sat Feb 18 13:26:01 2012 +0800
@@ -219,11 +219,5 @@
     return ('spid' => $spid);
 }
 
-sub error_list {
-    my ($class, @errors) = @_;
-    return unless @errors;
-
-    $class->error($_) foreach @errors;
-}
 
 1;
diff -r 40433367c052 -r 6888252ac285 doc/config-local.pl.txt
--- a/doc/config-local.pl.txt	Wed Feb 15 06:01:15 2012 +0800
+++ b/doc/config-local.pl.txt	Sat Feb 18 13:26:01 2012 +0800
@@ -56,6 +56,11 @@
             private_key => $DW::PRIVATE::RECAPTCHA{private_key},
         );
 
+    # setup textcaptcha
+    %TEXTCAPTCHA = (
+            api_key => $DW::PRIVATE::TEXTCAPTCHA{api_key},
+    );
+
     # If enabled, disable people coming in over Tor exits from using various parts of the site.
     $USE_TOR_CONFIGS = 0;
 
diff -r 40433367c052 -r 6888252ac285 doc/config-private.pl.txt
--- a/doc/config-private.pl.txt	Wed Feb 15 06:01:15 2012 +0800
+++ b/doc/config-private.pl.txt	Sat Feb 18 13:26:01 2012 +0800
@@ -169,6 +169,13 @@
     #    public_key  => ,
     #    private_key => ,
     #);
+
+    #%TEXTCAPTCHA = (
+    #   # this works for testing purposes.
+    #   # sign up at the textcaptcha website for a key for production use
+    #    api_key => "demo",
+    #    timeout => 10,
+    #);
 }
 
 1;
diff -r 40433367c052 -r 6888252ac285 etc/config.pl
--- a/etc/config.pl	Wed Feb 15 06:01:15 2012 +0800
+++ b/etc/config.pl	Sat Feb 18 13:26:01 2012 +0800
@@ -141,6 +141,7 @@
                  adult_content => 0,
                  blockwatch => 1,
                  'community-logins' => 0,
+                 captcha => 0,
                  directory => 0,
                  esn_archive => 1,
                  eventlogrecord => 1,
@@ -164,6 +165,16 @@
     #    ref $_[0] && defined $_[0]->{sitename} &&
     #        $_[0]->{sitename} eq 'LiveJournal' ? 1 : 0 };
 
+    # disable the recaptcha module, but keep textcaptcha enabled
+    # you'd probably want to edit $LJ::DEFAULT_CAPTCHA_TYPE in this case
+    #$DISABLED{captcha} = sub {
+    #   my $module = $_[0];
+    #   return 1 if $module eq "recaptcha";
+    #   return 0 if $module eq "textcaptcha";
+    #   return 0;
+    #};
+
+
     # turn $SERVER_DOWN on while you do any maintenance
     $SERVER_TOTALLY_DOWN = 0;
     $SERVER_DOWN = 0;
@@ -838,6 +849,7 @@
     # on why they're banned
     # $BLOCKED_ANON_URI = '';
 
+    # pages where we want to see captcha
     %CAPTCHA_FOR = (
         create   => 0,
         lostinfo => 1,
diff -r 40433367c052 -r 6888252ac285 htdocs/create.bml
--- a/htdocs/create.bml	Wed Feb 15 06:01:15 2012 +0800
+++ b/htdocs/create.bml	Sat Feb 18 13:26:01 2012 +0800
@@ -39,9 +39,11 @@
     my $code;
     my $rate_ok;
     if ( LJ::did_post() ) {
-        LJ::Widget->use_specific_form_fields( post => \%POST, widget => "CreateAccount", fields => [ DW::Captcha->form_fields ] )
-            if DW::Captcha->enabled( 'create' );
-        %from_post = LJ::Widget->handle_post( \%POST, ( 'CreateAccount' ) );
+        my $post_args = DW::Request->get->post_args;
+        my $captcha = DW::Captcha->new( 'create' );
+        LJ::Widget->use_specific_form_fields( post => $post_args, widget => "CreateAccount", fields => [ $captcha->form_fields ] )
+            if $captcha->enabled( 'create' );
+        %from_post = LJ::Widget->handle_post( $post_args, ( 'CreateAccount' ) );
     } else {
         # we always need the code, because it might contain paid time
         $code = LJ::trim( $GET{code} );
diff -r 40433367c052 -r 6888252ac285 htdocs/js/dw/dw-core.js
--- a/htdocs/js/dw/dw-core.js	Wed Feb 15 06:01:15 2012 +0800
+++ b/htdocs/js/dw/dw-core.js	Sat Feb 18 13:26:01 2012 +0800
@@ -54,6 +54,10 @@
   image: function() { return $("<img>", { src:  $.throbber.src } ) }
 };
 
+$.endpoint = function(action){
+  return Site && Site.currentJournal ? "/" + Site.currentJournal + "/__rpc_" + action : "/__rpc_" + action;
+};
+
 // position is an optional first argument
 $.fn.throbber = function(position, jqxhr) {
     var $this = $(this);
diff -r 40433367c052 -r 6888252ac285 htdocs/lostinfo.bml
--- a/htdocs/lostinfo.bml	Wed Feb 15 06:01:15 2012 +0800
+++ b/htdocs/lostinfo.bml	Sat Feb 18 13:26:01 2012 +0800
@@ -24,9 +24,7 @@
 
     LJ::set_active_crumb('lostinfo');
 
-    my %captcha_fields;
-    @captcha_fields{DW::Captcha->form_fields} = @POST{DW::Captcha->form_fields};
-    my $captcha = DW::Captcha->new( 'lostinfo', %captcha_fields );
+    my $captcha = DW::Captcha->new( 'lostinfo', %POST );
 
     my $captcha_error;
 
@@ -38,7 +36,7 @@
             if $captcha->enabled;
 
         $ret .= "<form action='lostinfo' method='post'>\n";
-
+        $ret .= LJ::form_auth();
         $ret .= $captcha->print;
 
         $ret .= "<?h1 $ML{'.lostpassword.title'} h1?>\n";
@@ -81,6 +79,7 @@
     }
 
     # we have a post action
+    return LJ::bad_input( LJ::Lang::ml( "error.invalidform" ) ) unless LJ::check_form_auth();
 
     # note: some lostinfo_do.bml translation strings are used below
     # because the code was once in lostinfo_do.bml, but later
diff -r 40433367c052 -r 6888252ac285 htdocs/manage/settings/index.bml
--- a/htdocs/manage/settings/index.bml	Wed Feb 15 06:01:15 2012 +0800
+++ b/htdocs/manage/settings/index.bml	Sat Feb 18 13:26:01 2012 +0800
@@ -133,6 +133,7 @@
                 LJ::Setting::EnableComments
                 LJ::Setting::CommentScreening
                 LJ::Setting::CommentCaptcha
+                DW::Setting::Captcha
                 LJ::Setting::CommentIP
                 LJ::Setting::Display::BanUsers
                 DW::Setting::AllowVgiftsFrom
diff -r 40433367c052 -r 6888252ac285 htdocs/register.bml
--- a/htdocs/register.bml	Wed Feb 15 06:01:15 2012 +0800
+++ b/htdocs/register.bml	Sat Feb 18 13:26:01 2012 +0800
@@ -104,6 +104,7 @@
         my $captcha = DW::Captcha->new( 'validate_openid', %POST );
 
         if ( $captcha->response ) {
+            return LJ::bad_input( "error.invalidform" ) unless LJ::check_form_auth() && $captcha->enabled;
             my $err_ref;
             return LJ::bad_input( $err_ref )
                 unless $captcha->validate( err_ref => \$err_ref );
@@ -111,6 +112,7 @@
             my $ret = "<?h1 $ML{'captcha.title'} h1?>";
             $ret .= "<form method='POST' action='$LJ::SITEROOT/register'><input type='hidden' name='qs' value='$qs'>";
             $ret .= $captcha->print;
+            $ret .= LJ::form_auth() if $captcha->enabled;
             $ret .= "<input type='submit' value='$ML{'.form.submit'}' ></form>";
             return $ret;
         }
diff -r 40433367c052 -r 6888252ac285 htdocs/stc/textcaptcha.css
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/stc/textcaptcha.css	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,6 @@
+body { font-family: sans-serif; padding: 0; margin: 0; }
+p { line-height: 1.5em; margin: 0; padding: 0;}
+textarea { height: 2.5em; width: 50%; display: block; }
+
+form, .textcaptcha {padding: 0 !important; margin: 0;}
+.
diff -r 40433367c052 -r 6888252ac285 htdocs/support/submit.bml
--- a/htdocs/support/submit.bml	Wed Feb 15 06:01:15 2012 +0800
+++ b/htdocs/support/submit.bml	Sat Feb 18 13:26:01 2012 +0800
@@ -20,9 +20,12 @@
     use strict;
     LJ::set_active_crumb('supportsubmit');
 
-    LJ::Widget->use_specific_form_fields( post => \%POST, widget => "SubmitRequest_Support", fields => [ DW::Captcha->form_fields ] );
-    return LJ::Widget->handle_post_and_render(\%POST, 'LJ::Widget::SubmitRequest::Support',
+    my $captcha = DW::Captcha->new();
+    LJ::Widget->use_specific_form_fields( post => \%POST, widget => "SubmitRequest_Support", fields => [ $captcha->form_fields ] );
+    my $ret = LJ::Widget->handle_post_and_render(\%POST, 'LJ::Widget::SubmitRequest::Support',
                                               'category' => $GET{category}, 'post' => LJ::Widget::SubmitRequest::Support->post_fields(\%POST));
+    my @errors = LJ::Widget->error_list;
+    return @errors ? LJ::error_list( @errors ) . $ret : $ret;
 }
 _code?>
 <=body
diff -r 40433367c052 -r 6888252ac285 t/captcha-textcaptcha.t
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/t/captcha-textcaptcha.t	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,129 @@
+# -*-perl-*-
+
+use strict;
+use Test::More tests => 21;
+use lib "$ENV{LJHOME}/cgi-bin";
+require 'ljlib.pl';
+
+use DW::Captcha;
+use XML::Simple;
+
+my $fakeanswers_single = {
+    question => 'The white bank is what colour?',
+    answer   => 'd508fe45cecaf653904a0e774084bb5c',
+};
+
+my $fakeanswers_multiple = {
+    question => 'If I have twelve monkeys, how many monkeys do I have?',
+    answer   => [ "c20ad4d76fe97759aa27a0c99bff6710",   # 12
+                  "15f6f8dc036519d7fe15b39338f6e5db",   # twelve
+                ],
+};
+
+# convenience method to generate and handle answers for the captcha
+# to make multiple inputs to the test easier to understand
+sub _run_test {
+    local $Test::Builder::Level = $Test::Builder::Level + 2;
+
+    my ( $content, $auth, $answer, $testmsg, $fail ) = @_;
+
+    subtest "generating and testing captcha" => sub {
+        # generate new captcha auth for each, because we can't reuse captcha on this form
+        # note: we can reuse the form auth! might have a need to pull in alternate captcha for same form
+        my $captcha = DW::Captcha::textCAPTCHA::Logic::form_data( $content, $auth );
+        my $checked = DW::Captcha::textCAPTCHA::Logic::check_answer( $captcha->{answers}, $answer, $auth, $captcha->{chal} );
+
+        $fail ? ok( ! $checked, $testmsg ) : ok( $checked, $testmsg );
+    }
+}
+
+note( "single answer" );
+{
+    LJ::start_request();
+    my $content = XML::Simple::XMLout( $fakeanswers_single, NoAttr => 1 );
+    my $auth = LJ::form_auth( 1 );
+    my $captcha = DW::Captcha::textCAPTCHA::Logic::form_data( $content, $auth );
+
+    # we want the question to be the same
+    # but we know the answer will be different -- we don't know and don't care what it will be though
+    is( $captcha->{question}, $fakeanswers_single->{question}, "got back the question for use in a form" );
+    isnt( $captcha->{answers}->[0], $fakeanswers_single->{answer}, "got back an answer for use in a form (which does not look like what we put in)" );
+
+    isnt( $captcha->{chal}, $auth, "Form auth and captcha auth are not the same" );
+
+    my $test_captcha = sub {
+        my ( $answer, $msg, %opts ) = @_;
+        return _run_test( $content, $auth, $answer, $msg, $opts{fail} ? 1 : 0 );
+    };
+
+    # now validate user responses
+    $test_captcha->( "blue"  , "completely incorrect", fail => 1 );
+    $test_captcha->( "white" , "correct" );
+    $test_captcha->( "WHITE" , "correct (caps)" );
+    $test_captcha->( "white ", "correct (whitespace)" );
+
+    LJ::start_request();
+    $captcha = DW::Captcha::textCAPTCHA::Logic::form_data( $content, $auth );
+    ok( ! DW::Captcha::textCAPTCHA::Logic::check_answer( $captcha->{answers}, "white", LJ::form_auth( 1 ), $captcha->{chal} ), "incorrect (auth; tried to submit captcha on a different form?)" );
+}
+
+note( "multiple valid answers" );
+{
+    LJ::start_request();
+    my $content = XML::Simple::XMLout( $fakeanswers_multiple, NoAttr => 1 );
+    my $auth = LJ::form_auth( 1 );
+    my $captcha = DW::Captcha::textCAPTCHA::Logic::form_data( $content, $auth );
+
+    my %original_answers = map { $_ => 1 } @{$fakeanswers_multiple->{answer}};
+    is( $captcha->{question}, $fakeanswers_multiple->{question}, "got back the question for use in a form" );
+
+    foreach ( @{ $captcha->{answers} } ) {
+        ok( !$original_answers{$_}, "one ofs multiple answers for use in a form (which does not look like what we put in)" );
+    }
+
+    my $test_captcha = sub {
+        my ( $answer, $msg, %opts ) = @_;
+        return _run_test( $content, $auth, $answer, $msg, $opts{fail} ? 1 : 0 );
+    };
+
+    # now validate user responses
+    $test_captcha->( "12"     , "correct ('12' is one of the valid choices)" );
+    $test_captcha->( "twelve" , "correct ('twelve' is another of the valid choices)" );
+    $test_captcha->( "a dozen", "incorrect ('a dozen' is not one of the valid choices)", fail => 1 );
+};
+
+note( "no form auth passed in" );
+{
+    my $content = XML::Simple::XMLout( $fakeanswers_single, NoAttr => 1 );
+    my $captcha = DW::Captcha::textCAPTCHA::Logic::form_data( $content, "" );
+    my $captcha_auth = $captcha->{chal};
+
+    # we want the question to be the same
+    # but we know the answer will be different -- we don't know and don't care what it will be though
+    is( $captcha->{question}, $fakeanswers_single->{question}, "got back the question for use in a form" );
+    isnt( $captcha->{answers}->[0], $fakeanswers_single->{answer}, "got back an answer for use in a form (which does not look like what we put in)" );
+
+    # now validate user response
+    ok( ! DW::Captcha::textCAPTCHA::Logic::check_answer( $captcha->{answers}, "white", "", $captcha_auth ), "correct answer, but we have no auth" );
+};
+
+note( "tried to reuse captcha + form_auth" );
+{
+    LJ::start_request();
+    my $content = XML::Simple::XMLout( $fakeanswers_single, NoAttr => 1 );
+    my $auth = LJ::form_auth( 1 );
+    my $captcha = DW::Captcha::textCAPTCHA::Logic::form_data( $content, $auth );
+    my $captcha_auth = $captcha->{chal};
+
+    # we want the question to be the same
+    # but we know the answer will be different -- we don't know and don't care what it will be though
+    is( $captcha->{question}, $fakeanswers_single->{question}, "got back the question for use in a form" );
+    isnt( $captcha->{answers}->[0], $fakeanswers_single->{answer}, "got back an answer for use in a form (which does not look like what we put in)" );
+
+    # now validate user response
+    ok( DW::Captcha::textCAPTCHA::Logic::check_answer( $captcha->{answers}, "white", $auth, $captcha_auth ), "correct" );
+
+    # whoo captcha succeded! let's try to reuse it
+    LJ::start_request();
+    ok( ! DW::Captcha::textCAPTCHA::Logic::check_answer( $captcha->{answers}, "white", $auth, $captcha_auth ), "tried to reuse captcha results" );
+};
diff -r 40433367c052 -r 6888252ac285 t/captcha.t
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/t/captcha.t	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,68 @@
+# -*-perl-*-
+
+use strict;
+use Test::More tests => 14;
+use lib "$ENV{LJHOME}/cgi-bin";
+require 'ljlib.pl';
+
+# override for the sake of the test
+%LJ::CAPTCHA_FOR = (
+    testpage => 1,
+    nocaptchapage => 0,
+);
+
+note( "disabled captcha for a specific page" );
+{
+    my $captcha = DW::Captcha->new( "nocaptchapage" );
+    ok( ! $captcha->enabled, "Captcha is not enabled for nocaptchapage" );
+}
+
+note( "check captcha is enabled" );
+{
+    my $captcha = DW::Captcha->new( "testpage" );
+    ok( $captcha->enabled, "Captcha is enabled for testpage" );
+}
+
+note( "check various implementations are loaded okay" );
+{
+    my $default = $LJ::CAPTCHA_TYPES{$LJ::DEFAULT_CAPTCHA_TYPE};
+
+    ok( DW::Captcha->site_enabled, "Captcha is enabled site-wide" );
+    my $captcha = DW::Captcha->new( 'testpage' );
+    is( $captcha->name, $default, "Use default captcha implementation" );
+
+
+    $captcha = DW::Captcha->new( 'testpage', want => 'I' );
+    is( $captcha->name, "recaptcha", "Using reCAPTCHA" );
+
+    # can also be done using DW::Captcha::reCAPTCHA->site_enabled
+    # but technically we shouldn't be worrying about module names
+    ok( $captcha->site_enabled, "reCAPTCHA is enabled and configured on this site" );
+
+
+    $captcha = DW::Captcha->new( 'testpage', want => 'T' );
+    is( $captcha->name, "textcaptcha", "Using textCAPTCHA" );
+    ok( $captcha->site_enabled, "textCAPTCHA is enabled and configured on this site" );
+
+    $captcha = DW::Captcha->new( 'testpage', want => 'abc' );
+    is( $captcha->name, $default, "not a valid captcha implementation, so used default" );
+    ok( $captcha->site_enabled, "not a valid captcha implementation, so used default to make sure we still get captcha" );
+}
+
+note( "user tries to use a disabled captcha type" );
+{
+    local %LJ::DISABLED = ( captcha  => sub {
+        my $module = $_[0];
+        return 0 if $module eq "recaptcha";
+        return 1 if $module eq "textcaptcha";
+    } );
+    local $LJ::DEFAULT_CAPTCHA_TYPE = "I";
+
+    my $captcha = DW::Captcha->new( "testpage", want => "I" ); # image
+    is( $captcha->name, "recaptcha", "want recaptcha, everything is fine" );
+    ok( $captcha->site_enabled, "recaptcha was enabled" );
+
+    my $captcha = DW::Captcha->new( "testpage", want => "T" ); # text
+    is( $captcha->name, "recaptcha", "wanted textcaptcha, but it's not enabled so use recaptcha instead" );
+    ok( $captcha->site_enabled, "recaptcha (our fallback) is enabled" );
+}
diff -r 40433367c052 -r 6888252ac285 views/textcaptcha-response.tt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/views/textcaptcha-response.tt	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,21 @@
+[%# textcaptcha-response.tt
+
+Displays captcha-related info the user needs to paste into their main form
+
+Authors:
+    Afuna <coder.dw@afunamatata.com>
+
+This program is free software; you may redistribute it and/or modify it under
+the same terms as Perl itself.  For a copy of the license, please reference
+'perldoc perlartistic' or 'perldoc perlgpl'.
+-%]
+
+[% dw.need_res( 'stc/textcaptcha.css' ) %]
+<div class="response">
+[%- label ='.response.iframe.label' | ml -%]
+[%- form.textarea( label = label
+    name = "captcha_response"
+    id = "captcha_response"
+    value = response
+) -%]
+</div>
diff -r 40433367c052 -r 6888252ac285 views/textcaptcha-response.tt.text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/views/textcaptcha-response.tt.text	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,4 @@
+;; -*- coding: utf-8 -*-
+.response.iframe.label=Captcha Response (copy this)
+
+.response.user.label=Captcha Response (paste here)
diff -r 40433367c052 -r 6888252ac285 views/textcaptcha.tt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/views/textcaptcha.tt	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,61 @@
+[%# textcaptcha.tt
+
+Displays an instance of textCAPTCHA, to be pulled into a page
+
+Authors:
+    Afuna <coder.dw@afunamatata.com>
+
+This program is free software; you may redistribute it and/or modify it under
+the same terms as Perl itself.  For a copy of the license, please reference
+'perldoc perlartistic' or 'perldoc perlgpl'.
+-%]
+
+[%- IF handle_submit -%]
+    [% dw.need_res( 'stc/textcaptcha.css' ) %]
+
+    <form method='POST'>
+        [%- IF error -%]
+            <p>[% error %]</p>
+        [%- ELSE -%]
+            <p>[% '.nojs.instructions' | ml %]</p>
+        [%- END -%]
+[%- END -%]
+
+<div class='textcaptcha' style='padding: 0.5em 0'>
+[%- IF captcha.question -%]
+    [%- form.textbox( label = captcha.question
+        id = "textcaptcha_response"
+        name = "textcaptcha_response"
+
+        maxlength = "255"
+        size = "50"
+
+        autocomplete = "off"
+    ) -%]
+
+    [%- FOREACH answer = captcha.answers;
+        form.hidden(
+            name = "textcaptcha_challenge"
+            value = answer
+        );
+    END -%]
+
+    [%- form.hidden(
+        name = "textcaptcha_chalauth"
+        value = captcha.chal
+    ) -%]
+
+    [%- IF handle_submit -%]
+        [%- form.hidden(
+            name = "lj_form_auth"
+            value = form_auth
+        ) -%]
+        [%- form.submit() -%]
+    </form>
+    [%- END -%]
+
+[%- ELSE -%]
+[%- ".error.noquestion" | ml -%]
+[%- END -%]
+
+</div>
diff -r 40433367c052 -r 6888252ac285 views/textcaptcha.tt.text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/views/textcaptcha.tt.text	Sat Feb 18 13:26:01 2012 +0800
@@ -0,0 +1,5 @@
+;; -*- coding: utf-8 -*-
+
+.error.noquestion=Sorry, the anti-spam test is not available right now. Please wait and try again.
+
+.nojs.instructions=Copy the result to the "Captcha Response" field
--------------------------------------------------------------------------------
ninetydegrees: Art & Text: heart with aroace colors, "you are loved" (Default)

[personal profile] ninetydegrees 2012-02-21 12:14 pm (UTC)(link)
(default will be textcaptcha rather than the graphical recaptcha)


This is awesome!