# GdSecurityImage - a CAPTCHA module for Oddmuse using GD::SecurityImage module # # Copyright (C) 2014 Aki Goto # # Codes reused from MwfCaptcha.pm in mwForum - Web-based discussion forum # Copyright (c) 1999-2014 Markus Wichitill # # Codes reused from questionasker.pl for Oddmuse # Copyright (C) 2004 Brock Wilcox # Copyright (C) 2006–2015 Alex Schroeder # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . use strict; use v5.10; AddModuleDescription('gd_security_image.pl', 'GD Security Image Extension'); our ($q, $Now, %Action, $FullUrl, $LinkPattern, $FreeLinks, $FreeLinkPattern, $WikiLinks, $DataDir, $ModuleDir, @MyInitVariables, %CookieParameters, @MyFormChanges); =head1 DESCRIPTION This is a CAPTCHA module for Oddmuse using GD::SecurityImage module. =head1 CONFIGURATION $GdSecurityImageFont Mandatory. Set a TTF font file used for generating CAPTCHA images. Example: '/usr/share/fonts/truetype/ttf-bitstream-vera/VeraBd.ttf'. $GdSecurityImageRememberAnswer If 1, once CAPTCHA is answered, the result is cached on cookies and you need not to re-answer CAPTCHAs for some duration specified by $GdSecurityImageDuration. If 0, CAPTCHA is requested everytime you try to submit forms. Default = 1. $GdSecurityImageDuration The duration a CAPTCHA ticket is valid in seconds. Default = 60 * 10 (10 minutes). $GdSecurityImageRequiredList The page name for exceptions, if defined. Every page linked to via WikiWord or [[free link]] is considered to be a page which needs questions asked. All other pages do not require questions asked. If not set, then all pages need questions asked. %GdSecurityImageProtectedForms Forms using one of the specified classes are protected. Default: ('comment' => 1, 'edit upload' => 1, 'edit text' => 1,). $GdSecurityImageDataDir When using with Namespaces Extension, specify original root data directory to concentrate GdSecurityImage data files in it. Default: $DataDir. $GdSecurityImageWidth Default: 250. $GdSecurityImageHeight Default: 60. $GdSecurityImagePtsize Default: 16. $GdSecurityImageScramble Default: 1. $GdSecurityImageChars Default: [qw(A B C D E F G H I J K L M O P R S T U V W X Y)]. =head1 API You can use this module in other modules by using following APIs. GdSecurityImageGetHtml returns CAPTCHA HTML form element for embedding in HTML form clause. GdSecurityImageCheck returns whether CAPTCHA is answered correctly or not. =head1 DATA STRUCTURE Image data and ticket data are stored in $DataDir/gd_security_image directory. Old data are deleted partially whenever CAPTCHA form is accessed. You can delete this directory totally harmlessly, although it forces users to re-answer CAPTCHA. =cut our ($GdSecurityImageFont, $GdSecurityImageRememberAnswer, $GdSecurityImageDuration, $GdSecurityImageRequiredList, %GdSecurityImageProtectedForms, $GdSecurityImageDataDir, $GdSecurityImageWidth, $GdSecurityImageHeight, $GdSecurityImagePtsize, $GdSecurityImageScramble, $GdSecurityImageChars, $GdSecurityImageAA); our ($GdSecurityImageDir, $GdSecurityImageId, $GdSecurityImagePngToAA); use Digest::MD5; use File::Glob ':glob'; $GdSecurityImageRequiredList = ''; $Action{gd_security_image} = \&GdSecurityImageDoImage; push(@MyInitVariables, \&GdSecurityImageInitVariables); sub GdSecurityImageGetImageFile { my ($id) = @_; return "$GdSecurityImageDir/$id.png"; } sub GdSecurityImageGetTicketFile { my ($id) = @_; return "$GdSecurityImageDir/$id.ticket"; } sub GdSecurityImageGenerate { # Load modules my $gd = eval { require GD }; eval { require Image::Magick } or ReportError(T('GD or Image::Magick modules not available.'), '500 INTERNAL SERVER ERROR') if !$gd; eval { require GD::SecurityImage } or ReportError(T('GD::SecurityImage module not available.')); # Generate captcha image GD::SecurityImage->import($gd ? () : (use_magick => 1)); my $img = GD::SecurityImage->new( width => $GdSecurityImageWidth, height => $GdSecurityImageHeight, font => $GdSecurityImageFont, ptsize => $GdSecurityImagePtsize, scramble => $GdSecurityImageScramble, rnd_data => $GdSecurityImageChars, bgcolor => '#000000', ); $img->random(); my $newCaptchaStr = $img->random_str(); $img->create('ttf', int(rand(2)) ? 'default' : 'ec', '#ffffff', '#ffffff'); $img->particle(3000); ### experimental ### #my $raw = $img->raw; #my $w2 = $GdSecurityImageWidth * 2 / 3; #my $h2 = $GdSecurityImageHeight * 2 / 3; #my $raw2 = GD::Image->new($w2, $h2); #$raw2->copyResampled($raw, 0, 0, 0, 0, $w2, $h2, $raw->getBounds); #my $png = $raw2->png; # Store captcha image my ($imgData) = $img->out(force => 'png'); my $ticketId = Digest::MD5::md5_hex(rand()); CreateDir($GdSecurityImageDir); open my $fh, ">:raw", encode_utf8(GdSecurityImageGetImageFile($ticketId)) or ReportError(Ts('Image storing failed. (%s)', $!), '500 INTERNAL SERVER ERROR'); print $fh $imgData; #print $fh $png; ### experimental ### close $fh; # Insert captcha ticket my %page = (); $page{id} = $ticketId; $page{generation_time} = $Now; $page{string} = $newCaptchaStr; CreateDir($GdSecurityImageDir); WriteStringToFile(GdSecurityImageGetTicketFile($ticketId), EncodePage(%page)); return $ticketId; } sub GdSecurityImageIsValidId { my ($id) = @_; return $id =~ /^[0-9a-f]+$/; } sub GdSecurityImageReadImageFile { if (open(my $IN, '<:raw', encode_utf8(shift))) { local $/ = undef; # Read complete files my $data=<$IN>; close $IN; return (1, $data); } return (0, ''); } sub GdSecurityImageDoImage { my $id = GetParam('gd_security_image_id', ''); if (!GdSecurityImageIsValidId($id)) { ReportError(T('Bad gd_security_image_id.'), '400 BAD REQUEST'); } my ($status, $data) = GdSecurityImageReadImageFile(GdSecurityImageGetImageFile($id)); binmode(STDOUT, ":raw"); print $q->header(-type=>'image/png'); print $data; Unlink(GdSecurityImageGetImageFile($id)); } sub GdSecurityImageCleanup { my ($id) = @_; if (!GdSecurityImageIsValidId($id)) { return; } my @files = (Glob("$GdSecurityImageDir/*.png"), Glob("$GdSecurityImageDir/*.ticket")); foreach my $file (@files) { if ($Now - Modified($file) > $GdSecurityImageDuration) { Unlink($file); } } } sub GdSecurityImageCheck { if (defined($GdSecurityImageId)) { return $GdSecurityImageId eq ''; } my $id = GetParam('gd_security_image_id', ''); my $answer = GetParam('gd_security_image_answer', ''); GdSecurityImageCleanup($id); if ($answer ne '' && GdSecurityImageIsValidId($id)) { my ($status, $data) = ReadFile(GdSecurityImageGetTicketFile($id)); if ($status) { my $page = ParseData($data); if ($page->{generation_time} + $GdSecurityImageDuration > $Now) { if ($answer eq $page->{string}) { $GdSecurityImageId = ''; if (!$GdSecurityImageRememberAnswer) { SetParam('gd_security_image_id', ''); SetParam('gd_security_image_answer', ''); } return 1; } } } } if (GdSecurityImageIsValidId($id)) { Unlink(GdSecurityImageGetTicketFile($id)); } $GdSecurityImageId = GdSecurityImageGenerate(); return 0; } sub GdSecurityImageGetHtml { if (GdSecurityImageCheck()) { return ''; } my $form = ''; SetParam('gd_security_image_answer', ''); $form .= $q->start_div({-class=>'gd_security_image'}); $form .= $q->start_div(); $form .= T('Please type the six characters from the anti-spam image'); $form .= $q->end_div(); $form .= $q->start_div(); $form .= $q->input({-type=>'hidden', -name=>'gd_security_image_id', -value=>$GdSecurityImageId}); $form .= $q->textfield(-name=>'gd_security_image_answer', -id=>'gd_security_image_answer'); $form .= $q->submit(-name=>'Submit', -value=>T('Submit')); $form .= $q->end_div(); $form .= $q->start_div(); $form .= $q->img({-src=>"$FullUrl?action=gd_security_image&gd_security_image_id=$GdSecurityImageId", -alt=>T('CAPTCHA'), -width=>$GdSecurityImageWidth, -height=>$GdSecurityImageHeight}); $form .= $q->end_div(); if ($GdSecurityImageAA) { $form .= $q->start_div({class=>'aa_captcha'}); $form .= $q->start_pre(); my $png_file = GdSecurityImageGetImageFile($GdSecurityImageId); $form .= `$GdSecurityImagePngToAA $png_file`; $form .= $q->end_pre(); $form .= $q->end_div(); } $form .= $q->end_div(); return $form; } *OldGdSecurityImageDoPost = \&DoPost; *DoPost = \&NewGdSecurityImageDoPost; sub NewGdSecurityImageDoPost { my(@params) = @_; my $id = FreeToNormal(GetParam('title', undef)); my $preview = GetParam('Preview', undef); # case matters! unless (UserIsEditor() or $preview or GdSecurityImageCheck() or GdSecurityImageException($id)) { print GetHeader('', T('Edit Denied'), undef, undef, '403 FORBIDDEN'); print $q->p(T('You did not answer correctly.')); print GetFormStart(), GdSecurityImageGetHtml(), (map { $q->input({-type=>'hidden', -name=>$_, -value=>UnquoteHtml(GetParam($_))}) } qw(title text oldtime summary recent_edit aftertext)), $q->end_form; PrintFooter(); # logging to the error log file of the server # warn "Q: '$QuestionaskerQuestions[$question_num][0]', A: '$answer'\n"; return; } return (OldGdSecurityImageDoPost(@params)); } push(@MyFormChanges, \&GdSecurityImageAddTo); sub GdSecurityImageAddTo { my ($form, $type, $upload) = @_; if (not $upload and not GdSecurityImageException(GetId()) and not UserIsEditor()) { my $question = GdSecurityImageGetHtml(); $form =~ s/(.*)

(.*?)

$2