#!/usr/bin/perl

use strict;
package LJ::Captcha;
use GD;
use File::Temp;
use Cwd ();
use Digest::MD5 ();
use LJ::Blob qw{};
require "$ENV{LJHOME}/cgi-bin/ljlib.pl";


# stolen from Authen::Captcha.  code was small enough that duplicating
# was easier than requiring that module, and removing all its automatic
# database tracking stuff and replacing it with ours.  maybe we'll move
# to using it in the future, but for now this works.  (both their code
# and ours is GPL)
sub generate_visual
{
    my ($code) = @_;
    
    my $im_width = 25;
    my $im_height = 35;
    my $length = length($code);

    my $img = "$LJ::HOME/htdocs/img/captcha";

    # create a new image and color
    my $im = new GD::Image(($im_width * $length),$im_height);
    my $black = $im->colorAllocate(0,0,0);
    
    # copy the character images into the code graphic
    for(my $i=0; $i < $length; $i++)
    {
        my $letter = substr($code,$i,1);
        my $letter_png = "$img/$letter.png";
        my $source = new GD::Image($letter_png);
        $im->copy($source,($i*($im_width),0,0,0,$im_width,$im_height));
        my $a = int(rand (int(($im_width)/14)))+0;
        my $b = int(rand (int(($im_height)/12)))+0;
        my $c = int(rand (int(($im_width)/3)))-(int(($im_width)/5));
        my $d = int(rand (int(($im_height)/3)))-(int(($im_height)/5));
        $im->copyResized($source,($i*($im_width))+$a,$b,0,0,($im_width)+$c,($im_height)+$d,$im_width,$im_height);
    }
    
    # distort the code graphic
    for(my $i=0; $i<($length*$im_width*$im_height/14+150); $i++)
    {
        my $a = int(rand($length*$im_width));
        my $b = int(rand($im_height));
        my $c = int(rand($length*$im_width));
        my $d = int(rand($im_height));
        my $index = $im->getPixel($a,$b);
        if ($i < (($length*($im_width)*($im_height)/14+200)/100))
        {
            $im->line($a,$b,$c,$d,$index);
        } elsif ($i < (($length*($im_width)*($im_height)/14+200)/2)) {
            $im->setPixel($c,$d,$index);
        } else {
            $im->setPixel($c,$d,$black);
        }
    }
    
    # generate a background
    my $a = int(rand 5)+1;
    my $background_img = "$img/background$a.png";
    my $source = new GD::Image($background_img);
    my ($background_width, $background_height) = $source->getBounds();
    my $b = int(rand (int($background_width/13)))+0;
    my $c = int(rand (int($background_height/7)))+0;
    my $d = int(rand (int($background_width/13)))+0;
    my $e = int(rand (int($background_height/7)))+0;
    my $source2 = new GD::Image(($length*($im_width)),$im_height);
    $source2->copyResized($source,0,0,$b,$c,$length*$im_width,$im_height,$background_width-$b-$d,$background_height-$c-$e);
    
    # merge the background onto the image
    $im->copyMerge($source2,0,0,0,0,($length*($im_width)),$im_height,40);
    
    # add a border
    $im->rectangle(0, 0, $length*$im_width-1, $im_height-1, $black);
    
    return $im->png;
    
}


### get_visual_id() -> ( $capid, $anum )
sub get_visual_id { get_id('image') }
sub get_audio_id { get_id('audio') }


### get_id( $type ) -> ( $capid, $anum )
sub get_id
{
    my ( $type ) = @_;
    my (
        $dbh,                   # Database handle (writer)
        $sql,                   # SQL statement
        $row,                   # Row arrayref
        $capid,                 # Captcha id
        $anum,                  # Unseries-ifier number
        $issuedate,             # unixtime of issue
       );

    # Fetch database handle and lock the captcha table
    $dbh = LJ::get_db_writer()
		or return LJ::error( "Couldn't fetch a db writer." );
    $dbh->selectrow_array("SELECT GET_LOCK('get_captcha', 10)")
                or return LJ::error( "Failed lock on getting a captcha." );

    # Fetch the first unassigned row
    $sql = q{
        SELECT capid, anum
        FROM captchas
        WHERE
            issuetime = 0
            AND type = ?
        LIMIT 1
    };
    $row = $dbh->selectrow_arrayref( $sql, undef, $type )
        or $dbh->do("DO RELEASE_LOCK('get_captcha')") && die "No $type captchas available";
    die "selectrow_arrayref: $sql: ", $dbh->errstr if $dbh->err;
    ( $capid, $anum ) = @$row;

    # Mark the captcha as issued
    $issuedate = time();
    $sql = qq{
        UPDATE captchas
        SET issuetime = $issuedate
        WHERE capid = $capid
    };
    $dbh->do( $sql ) or die "do: $sql: ", $dbh->errstr;
    $dbh->do("DO RELEASE_LOCK('get_captcha')");

    return ( $capid, $anum );
}


### get_visual_data( $capid, $anum, $want_paths )
# if want_paths is true, this function may return an arrayref containing
# one or more paths (disk or HTTP) to the resource
sub get_visual_data
{
    my ( $capid, $anum, $want_paths ) = @_;
    $capid = int($capid);

    my (
        $dbr,                   # Database handle (reader)
        $sql,                   # SQL statement
        $valid,                 # Are the capid/anum valid?
        $data,                  # The PNG data
        $u,                     # System user
        $location,              # Location of the file (mogile/blob)
       );

    $dbr = LJ::get_db_reader();
    $sql = q{
        SELECT capid, location
        FROM captchas
        WHERE
            capid = ?
            AND anum = ?
    };

    ( $valid, $location ) = $dbr->selectrow_array( $sql, undef, $capid, $anum );
    return undef unless $valid;

    if ($location eq 'mogile') {
        die "MogileFS object not loaded.\n" unless LJ::mogclient();
        if ($want_paths) {
            # return path(s) to the content if they want
            my @paths = LJ::mogclient()->get_paths("captcha:$capid");
            return \@paths;
        } else {
            $data = ${LJ::mogclient()->get_file_data("captcha:$capid")};
        }
    } else {
        $u = LJ::load_user( "system" )
            or die "Couldn't load the system user.";

        $data = LJ::Blob::get( $u, 'captcha_image', 'png', $capid )
              or die "Failed to fetch captcha_image $capid from media server";
    }
    return $data;
}


### get_audio_data( $capid, $anum, $want_paths )
# if want_paths is true, this function may return an arrayref containing
# one or more paths (disk or HTTP) to the resource
sub get_audio_data
{
    my ( $capid, $anum, $want_paths ) = @_;
    $capid = int($capid);

    my (
        $dbr,                   # Database handle (reader)
        $sql,                   # SQL statement
        $valid,                 # Are the capid/anum valid?
        $data,                  # The PNG data
        $u,                     # System user
        $location,              # Location of the file (mogile/blob)
       );

    $dbr = LJ::get_db_reader();
    $sql = q{
        SELECT capid, location
        FROM captchas
        WHERE
            capid = ?
            AND anum = ?
    };

    ( $valid, $location ) = $dbr->selectrow_array( $sql, undef, $capid, $anum );
    return undef unless $valid;

    if ($location eq 'mogile') {
        die "MogileFS object not loaded.\n" unless LJ::mogclient();
        if ($want_paths) {
            # return path(s) to the content if they want
            my @paths = LJ::mogclient()->get_paths("captcha:$capid");
            return \@paths;
        } else {
            $data = ${LJ::mogclient()->get_file_data("captcha:$capid")};
        }
    } else {
        $u = LJ::load_user( "system" )
            or die "Couldn't load the system user.";

        $data = LJ::Blob::get( $u, 'captcha_audio', 'wav', $capid )
              or die "Failed to fetch captcha_audio $capid from media server";
    }
    return $data;
}



# ($dir) -> ("$dir/speech.wav", $code)
#  Callers must:
#    -- create unique temporary directory, shared by no other process
#       calling this function
#    -- after return, do something with speech.wav (save on disk server/
#       db/etc), remove speech.wav, then rmdir $dir
#  Requires festival and sox.
sub generate_audio
{
    my ($dir) = @_;
    my $old_dir =  Cwd::getcwd();
    chdir($dir) or return 0;

    my $bin_festival = $LJ::BIN_FESTIVAL || "festival";
    my $bin_sox = $LJ::BIN_SOX || "sox";

    # make up 7 random numbers, without any numbers in a row
    my @numbers;
    my $lastnum;
    for (1..7) {
        my $num;
        do {
            $num = int(rand(9)+1);
        } while ($num == $lastnum);
        $lastnum = $num;
        push @numbers, $num;
    }
    my $numbers_speak = join("... ", @numbers);
    my $numbers_clean = join('', @numbers);

    # generate the clean speech
    open FEST, '|-', $bin_festival or die "Couldn't invoke festival";
    print FEST "(Parameter.set 'Audio_Method 'Audio_Command)\n";
    print FEST "(Parameter.set 'Audio_Required_Format 'wav)\n";
    print FEST "(Parameter.set 'Audio_Required_Rate 44100)\n";
    print FEST "(Parameter.set 'Audio_Command \"mv \$FILE speech.wav\")\n";
    print FEST "(SayText \"$numbers_speak\")\n";
    close FEST or die "Error closing festival";
    
    my $sox = sub {
        my ($effect, $filename, $inopts, $outopts) = @_;
        $effect = [] unless $effect;
        $filename = "speech.wav" unless $filename;
        $inopts = [] unless $inopts;
        $outopts = [] unless $outopts;
        command($bin_sox, @$inopts, $filename, @$outopts, "tmp.wav", @$effect);
        rename('tmp.wav', $filename)
            or die;
    };

    # distort the speech
    $sox->([qw(reverb 0.5 200 100 60 echo 1 0.7 100 0.03 400 0.15)]);
    command($bin_sox, qw(speech.wav noise.wav synth brownnoise 0 vibro 3 0.8 vol 0.1));
    $sox->([qw(fade 0.5)], 'noise.wav');
    $sox->([qw(reverse)], 'noise.wav');
    $sox->([qw(fade 0.5)], 'noise.wav');

    command("${bin_sox}mix", qw(-v 4 speech.wav noise.wav -r 16000 tmp.wav));
    rename('tmp.wav', 'speech.wav') or die;
    unlink('oldspeech.wav', 'noise.wav');
    
    chdir($old_dir) or return 0;
    return ("$dir/speech.wav", $numbers_clean);
}

sub command {
    system(@_) >> 8 == 0 or die "audio command failed, died";
}


### check_code( $capid, $anum, $code, $u ) -> <true value if code is correct>
sub check_code {
    my ( $capid, $anum, $code, $u ) = @_;

    my (
        $dbr,                   # Database handle (reader)
        $sql,                   # SQL query
        $answer,                # Challenge answer
        $userid,                # userid of previous answerer (or 0 if none)
       );

    $sql = q{
        SELECT answer, userid
        FROM captchas
        WHERE
            capid = ?
            AND anum = ?
    };

    # Fetch the challenge's answer based on id and anum.
    $dbr = LJ::get_db_writer();
    ( $answer, $userid ) = $dbr->selectrow_array( $sql, undef, $capid, $anum );

    # if it's already been answered, it must have been answered by the $u
    # given to this function (double-click protection)
    return 0 if $userid && ( ! $u || $u->{userid} != $userid );

    # otherwise, just check answer.
    return lc $answer eq lc $code;
}

# Verify captcha answer if using a captcha session.
# (captcha challenge, code, $u)
# Returns capid and anum if answer correct. (for expire)
sub session_check_code {
    my ($sess, $code, $u) = @_;
    return 0 unless $sess && $code;
    $sess = LJ::get_challenge_attributes($sess);

    $u = LJ::load_user('system') unless $u;

    my $dbcm = LJ::get_cluster_master($u);
    my $dbr = LJ::get_db_reader();
    
    my ($lcapid, $try) =  # clustered
        $dbcm->selectrow_array('SELECT lastcapid, trynum ' .
                               'FROM captcha_session ' .
                               'WHERE sess=?', undef, $sess);
    my ($capid, $anum) =  # global
        $dbr->selectrow_array('SELECT capid,anum ' .
                              'FROM captchas '.
                              'WHERE capid=?', undef, $lcapid);
    if (! LJ::Captcha::check_code($capid, $anum, $code, $u)) {
        # update try and lastcapid
        $u->do('UPDATE captcha_session SET lastcapid=NULL, ' .
               'trynum=trynum+1 WHERE sess=?', undef, $sess);
        return 0;
    }
    return ($capid, $anum);
}

### expire( $capid ) -> <true value if code was expired successfully>
sub expire {
    my ( $capid, $anum, $userid ) = @_;

    my (
        $dbh,                   # Database handle (writer)
        $sql,                   # SQL update query
       );

    $sql = q{
        UPDATE captchas
        SET userid = ?
        WHERE capid = ? AND anum = ? AND userid = 0
    };

    # Fetch the challenge's answer based on id and anum.
    $dbh = LJ::get_db_writer();
    $dbh->do( $sql, undef, $userid, $capid, $anum ) or return undef;

    return 1;
}

# Update/create captcha sessions, return new capid/anum pairs on success.
# challenge, type, optional journalu->{clusterid} for clustering.
# Type is either 'image' or 'audio'
sub session
{
    my ($chal, $type, $cid) = @_;
    return unless $chal && $type;

    my $chalinfo = {};
    LJ::challenge_check($chal, $chalinfo);
    return unless $chalinfo->{valid};

    my $sess = LJ::get_challenge_attributes($chal);
    my ($capid, $anum) = ($type eq 'image') ?
                         LJ::Captcha::get_visual_id() :
                         LJ::Captcha::get_audio_id();


    $cid = LJ::load_user('system')->{clusterid} unless $cid;
    my $dbcm = LJ::get_cluster_master($cid);

    # Retain try count
    my $try = $dbcm->selectrow_array('SELECT trynum FROM captcha_session ' .
                                     'WHERE sess=?', undef, $sess);
    $try ||= 0;
    # Add/update session
    $dbcm->do('REPLACE INTO captcha_session SET sess=?, sesstime=?, '.
              'lastcapid=?, trynum=?', undef, $sess, time(), $capid, $try);
    return ($capid, $anum);
}


1;
