acts_as_textcaptcha 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gemspec
2
+ *.gem
3
+ *.db
4
+ doc
5
+ coverage
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Matthew Hutchinson
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,37 @@
1
+ = Act as TextCaptcha
2
+
3
+ == About
4
+
5
+ == Caveats
6
+
7
+ Ongoing issues with this project include;
8
+
9
+ * needs tests
10
+
11
+ == Setup / Using
12
+
13
+ === Requirements
14
+
15
+ What do you need?
16
+
17
+ === Installing
18
+
19
+ script/plugin install git://github.com/hiddenloop/acts_as_textcaptcha
20
+
21
+ == Using
22
+
23
+ == What does the code do?
24
+
25
+ == Credits
26
+
27
+ Who's who?
28
+
29
+ * Authored by "Matthew Hutchinson":http://matthewhutchinson.net
30
+
31
+ == Get out clause
32
+
33
+ Right now this script is provided without warranty, or support from the author.
34
+
35
+ == Creative Commons License
36
+
37
+ <a rel="license" href="http://creativecommons.org/licenses/by/2.0/uk/"><img alt="Creative Commons License" style="border-width:0" src="http://i.creativecommons.org/l/by/2.0/uk/80x15.png" /></a>
data/Rakefile ADDED
@@ -0,0 +1,48 @@
1
+ require 'rake'
2
+ require 'rake/rdoctask'
3
+ require 'spec/rake/spectask'
4
+ require 'spec'
5
+
6
+ desc 'Default: run spec tests.'
7
+ task :default => :spec
8
+ task :test => :spec
9
+
10
+ desc "Run all specs"
11
+ Spec::Rake::SpecTask.new(:spec) do |t|
12
+ t.spec_files = FileList['spec/**/*_spec.rb']
13
+ t.spec_opts = ['--options', 'spec/spec.opts']
14
+ end
15
+
16
+ desc "Run all specs with RCov"
17
+ Spec::Rake::SpecTask.new(:rcov) do |t|
18
+ t.spec_files = FileList['spec/**/*_spec.rb']
19
+ t.rcov = true
20
+ t.rcov_opts = ['--exclude', 'spec']
21
+ end
22
+
23
+ desc 'Generate documentation for the acts_as_textcaptcha plugin.'
24
+ Rake::RDocTask.new(:rdoc) do |rdoc|
25
+ rdoc.rdoc_dir = 'doc'
26
+ rdoc.title = 'acts_as_textcaptcha'
27
+ rdoc.options << '--line-numbers' << '--inline-source'
28
+ rdoc.rdoc_files.include('README.rdoc', 'LICENSE')
29
+ rdoc.rdoc_files.include('lib/**/*.rb')
30
+ end
31
+
32
+
33
+ begin
34
+ require 'jeweler'
35
+ Jeweler::Tasks.new do |gemspec|
36
+ gemspec.name = "acts_as_textcaptcha"
37
+ gemspec.summary = "Spam protection for your models via logic questions and the excellent textcaptcha.com api"
38
+ gemspec.description = "Spam protection for your ActiveRecord models using logic questions and the excellent textcaptcha api. See textcaptcha.com for more details and to get your api key.
39
+ The logic questions are aimed at a child's age of 7, so can be solved easily by all but the most cognitively impaired users. As they involve human logic, such questions cannot be solved by a robot.
40
+ For more reasons on why logic questions are useful, see here; http://textcaptcha.com/why"
41
+ gemspec.email = "matt@hiddenloop.com"
42
+ gemspec.homepage = "http://github.com/hiddenloop/acts_as_textcaptcha"
43
+ gemspec.authors = ["Matthew Hutchinson"]
44
+ end
45
+ Jeweler::GemcutterTasks.new
46
+ rescue LoadError
47
+ puts "Jeweler not available. Install it with: gem install jeweler"
48
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.1.0
@@ -0,0 +1,36 @@
1
+ development: &non_production_settings
2
+ api_key: 8u5ixtdnq9csc84cok0owswgo
3
+ # bcrypt can be used to encrypt valid possible answers in your session; http://bcrypt-ruby.rubyforge.org/
4
+ # (recommended if you are using cookie session storage)
5
+ # NOTE: bcrypt_salt must be a valid bcrypt salt; for security PLEASE CHANGE THIS, open irb and enter; require 'bcrypt'; BCrypt::Engine.generate_salt
6
+ bcrypt_salt: $2a$10$j0bmycH.SVfD1b5mpEGPpe
7
+ # an optional logarithmic var which determines how computational expensive the hash is to calculate (a cost of 4 is twice as much work as a cost of 3)
8
+ bcrypt_cost: 10 # default is 10, must be > 4 (large number means slower encryption)
9
+ # if you'd rather NOT use bcrypt, just remove these two settings, bcrypt_salt and bcrypt_cost, valid possible answers will be MD5 digested in your session
10
+ questions:
11
+ - question: 'Is ice hot or cold?'
12
+ answers: 'cold'
13
+ - question: 'what color is an orange?'
14
+ answers: 'orange'
15
+ - question: 'what is two plus 3?'
16
+ answers: '5,five'
17
+ - question: 'what is 5 times two?'
18
+ answers: '10,ten'
19
+ - question: 'How many colors in the list, green, brown, foot and blue?'
20
+ answers: '3,three'
21
+ - question: 'what is Georges name?'
22
+ answers: 'george'
23
+ - question: '11 minus 1?'
24
+ answers: '10,ten'
25
+ - question: 'is boiling water hot or cold?'
26
+ answers: 'hot'
27
+ - question: 'what color is my blue shirt today?'
28
+ answers: 'blue'
29
+ - question: 'what is 16 plus 4?'
30
+ answers: '20,twenty'
31
+
32
+ test:
33
+ *non_production_settings
34
+
35
+ production:
36
+ *non_production_settings
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.dirname(__FILE__) + '/rails/init'
@@ -0,0 +1,96 @@
1
+ begin
2
+ require 'xml'
3
+ require 'yaml'
4
+ require 'net/http'
5
+ require 'md5'
6
+ require 'logger'
7
+ require 'bcrypt'
8
+ rescue LoadError
9
+ end
10
+
11
+ module ActsAsTextcaptcha #:nodoc:
12
+
13
+ def acts_as_textcaptcha(options = nil)
14
+ cattr_accessor :textcaptcha_config
15
+ attr_accessor :spam_answer, :spam_question, :possible_answers
16
+
17
+ if options.is_a?(Hash)
18
+ self.textcaptcha_config = options
19
+ else
20
+ begin
21
+ self.textcaptcha_config = YAML.load(File.read("#{(defined? RAILS_ROOT) ? "#{RAILS_ROOT}" : '.'}/config/textcaptcha.yml"))[((defined? RAILS_ENV) ? RAILS_ENV : 'test')]
22
+ rescue Errno::ENOENT
23
+ raise('./config/textcaptcha.yml not found')
24
+ end
25
+ end
26
+
27
+ include InstanceMethods
28
+ end
29
+
30
+
31
+ module InstanceMethods
32
+
33
+ def skip_spam_check?; false end
34
+
35
+ def allowed?; true end
36
+
37
+ def validate
38
+ if new_record?
39
+ if allowed?
40
+ if possible_answers && !skip_spam_check? && !validate_spam_answer
41
+ errors.add(:spam_answer, 'is incorrect, try another question instead')
42
+ return false
43
+ end
44
+ else
45
+ errors.add_to_base("Sorry, #{self.class.name.pluralize.downcase} are currently disabled")
46
+ return false
47
+ end
48
+ end
49
+ true
50
+ end
51
+
52
+ def validate_spam_answer
53
+ spam_answer ? possible_answers.include?(encrypt_answer(Digest::MD5.hexdigest(spam_answer.strip.downcase.to_s))) : false
54
+ end
55
+
56
+ def encrypt_answers(answers)
57
+ answers.map {|answer| encrypt_answer(answer) }
58
+ end
59
+
60
+ def encrypt_answer(answer)
61
+ return answer unless(textcaptcha_config['bcrypt_salt'])
62
+ BCrypt::Engine.hash_secret(answer, textcaptcha_config['bcrypt_salt'], (textcaptcha_config['bcrypt_cost'] || 10))
63
+ end
64
+
65
+ def generate_spam_question(use_textcaptcha = true)
66
+ if use_textcaptcha && textcaptcha_config && textcaptcha_config['api_key']
67
+ begin
68
+ resp = Net::HTTP.get(URI.parse('http://textcaptcha.com/api/'+textcaptcha_config['api_key']))
69
+ if !resp.empty? && xml = XML::Parser.string(resp).parse
70
+ self.spam_question = xml.find('/captcha/question')[0].inner_xml
71
+ self.possible_answers = encrypt_answers(xml.find('/captcha/answer').map(&:inner_xml))
72
+ end
73
+ return possible_answers if spam_question && !possible_answers.empty?
74
+ rescue SocketError, Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, EOFError, Errno::ECONNREFUSED,
75
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, URI::InvalidURIError => e
76
+ log_textcaptcha("failed to load or parse textcaptcha with key '#{textcaptcha_config['api_key']}'; #{e}")
77
+ end
78
+ end
79
+
80
+ # fall back to textcaptcha_config questions
81
+ if textcaptcha_config && textcaptcha_config['questions']
82
+ log_textcaptcha('falling back to random logic question from config') if textcaptcha_config['api_key']
83
+ random_question = textcaptcha_config['questions'][rand(textcaptcha_config['questions'].size)]
84
+ self.spam_question = random_question['question']
85
+ self.possible_answers = encrypt_answers(random_question['answers'].split(',').map!{|ans| Digest::MD5.hexdigest(ans)})
86
+ end
87
+ possible_answers
88
+ end
89
+
90
+ private
91
+ def log_textcaptcha(message)
92
+ logger ||= Logger.new(STDOUT)
93
+ logger.info "Textcaptcha >> #{message}"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,6 @@
1
+ module TextcaptchaHelper
2
+
3
+ def spamify(model)
4
+ session[:possible_answers] = model.generate_spam_question unless model.validate_spam_answer
5
+ end
6
+ end
data/rails/init.rb ADDED
@@ -0,0 +1,9 @@
1
+ require 'acts_as_textcaptcha'
2
+
3
+ if defined?(ActiveRecord::Base)
4
+ ActiveRecord::Base.extend ActsAsTextcaptcha
5
+ end
6
+
7
+ if defined?(ActionController::Base)
8
+ ActionController::Base.send :include, TextcaptchaHelper
9
+ end
@@ -0,0 +1,183 @@
1
+ require File.join(File.dirname(__FILE__), 'spec_helper')
2
+
3
+ class Widget < ActiveRecord::Base
4
+ # uses textcaptcha.yml file for configuration
5
+ acts_as_textcaptcha
6
+ end
7
+
8
+ class Comment < ActiveRecord::Base
9
+ # inline options with api_key only
10
+ acts_as_textcaptcha({'api_key' => '8u5ixtdnq9csc84cok0owswgo'})
11
+ end
12
+
13
+ class Review < ActiveRecord::Base
14
+ # inline options with all possible options
15
+ acts_as_textcaptcha({'api_key' => '8u5ixtdnq9csc84cok0owswgo',
16
+ 'bcrypt_salt' => '$2a$10$j0bmycH.SVfD1b5mpEGPpe',
17
+ 'bcrypt_cost' => '3',
18
+ 'questions' => [{'question' => '1+1', 'answers' => '2,two'},
19
+ {'question' => 'The green hat is what color?', 'answers' => 'green'},
20
+ {'question' => 'Which is bigger: 67, 14 or 6', 'answers' => '67,sixtyseven,sixty seven,sixty-seven'}]})
21
+ end
22
+
23
+ class Note < ActiveRecord::Base
24
+ # inline options with user defined questions only (no textcaptcha service)
25
+ acts_as_textcaptcha('questions' => [{'question' => '1+1', 'answers' => '2,two'}])
26
+ end
27
+
28
+
29
+ describe 'ActsAsTextcaptcha' do
30
+
31
+ before(:each) do
32
+ @comment = Comment.new
33
+ @review = Review.new
34
+ @note = Note.new
35
+ end
36
+
37
+ describe 'validations' do
38
+
39
+ before(:each) do
40
+ @note.generate_spam_question
41
+ @note.validate.should be_false
42
+ end
43
+
44
+ it 'should validate spam answer with possible answers' do
45
+ @note.spam_answer = '2'
46
+ @note.validate.should be_true
47
+
48
+ @note.spam_answer = 'two'
49
+ @note.validate.should be_true
50
+
51
+ @note.spam_answer = 'wrong'
52
+ @note.validate.should be_false
53
+ end
54
+
55
+ it 'should strip whitespace and downcase spam answer' do
56
+ @note.spam_answer = ' tWo '
57
+ @note.validate.should be_true
58
+
59
+ @note.spam_answer = ' 2 '
60
+ @note.validate.should be_true
61
+ end
62
+
63
+ it 'should always validate if not a new record' do
64
+ @note.spam_answer = '2'
65
+ @note.save!
66
+ @note.generate_spam_question
67
+ @note.new_record?.should be_false
68
+ @note.validate.should be_true
69
+ end
70
+ end
71
+
72
+ describe 'encryption' do
73
+
74
+ it 'should encrypt answers' do
75
+ @review.encrypt_answers(['123', '456']).should eql(['$2a$10$j0bmycH.SVfD1b5mpEGPperNj9IlIHoieNk38UDQFdtREOmRFKgou',
76
+ '$2a$10$j0bmycH.SVfD1b5mpEGPpeqf88jqdV6gIgeJLQNjUnufIn8dys1fW'])
77
+ end
78
+
79
+ it 'should encrypt a single answer' do
80
+ @review.encrypt_answer('123').should eql('$2a$10$j0bmycH.SVfD1b5mpEGPperNj9IlIHoieNk38UDQFdtREOmRFKgou')
81
+ end
82
+
83
+ it 'should not encrypt if no bycrpt-salt set' do
84
+ @comment.encrypt_answer('123').should eql('123')
85
+ @comment.encrypt_answers(['123', '456']).should eql(['123', '456'])
86
+ end
87
+ end
88
+
89
+ describe 'flags' do
90
+
91
+ it 'should always be valid if skip_spam_check? is true' do
92
+ @comment.generate_spam_question
93
+ @comment.validate.should be_false
94
+ @comment.stub!(:skip_spam_check?).and_return(true)
95
+ @comment.validate.should be_true
96
+ @comment.should be_valid
97
+ end
98
+
99
+ it 'should always fail validation if allowed? is false' do
100
+ @comment.validate.should be_true
101
+ @comment.stub!(:allowed?).and_return(false)
102
+ @comment.validate.should be_false
103
+ @comment.errors.on(:base).should eql('Sorry, comments are currently disabled')
104
+ @comment.should_not be_valid
105
+ end
106
+ end
107
+
108
+ describe 'with inline options hash' do
109
+
110
+ it 'should be configurable from inline options' do
111
+ @comment.textcaptcha_config.should eql({'api_key' => '8u5ixtdnq9csc84cok0owswgo'})
112
+ @review.textcaptcha_config.should eql({'bcrypt_cost'=>'3', 'questions'=>[{'question'=>'1+1', 'answers'=>'2,two'}, {'question'=>'The green hat is what color?', 'answers'=>'green'}, {'question'=>'Which is bigger: 67, 14 or 6', 'answers'=>'67,sixtyseven,sixty seven,sixty-seven'}], 'bcrypt_salt'=>'$2a$10$j0bmycH.SVfD1b5mpEGPpe', 'api_key'=>'8u5ixtdnq9csc84cok0owswgo'})
113
+ @note.textcaptcha_config .should eql({'questions'=>[{'question'=>'1+1', 'answers'=>'2,two'}]})
114
+ end
115
+
116
+ it 'should generate spam question from textcaptcha service' do
117
+ @comment.generate_spam_question
118
+ @comment.spam_question.should_not be_nil
119
+ @comment.possible_answers.should_not be_nil
120
+ @comment.possible_answers.should be_an(Array)
121
+ @comment.validate.should be_false
122
+ end
123
+
124
+ describe 'and textcaptcha unavailable' do
125
+
126
+ before(:each) do
127
+ Net::HTTP.stub(:get).and_raise(SocketError)
128
+ end
129
+
130
+ it 'should fall back to random user defined question when set' do
131
+ @review.generate_spam_question
132
+ @review.spam_question.should_not be_nil
133
+ @review.possible_answers.should_not be_nil
134
+ @review.possible_answers.should be_an(Array)
135
+ @review.validate.should be_false
136
+ end
137
+
138
+ it 'should not generate any spam question/answer if no user defined questions set' do
139
+ @comment.generate_spam_question
140
+ @comment.spam_question.should be_nil
141
+ @comment.possible_answers.should be_nil
142
+ @comment.validate.should be_true
143
+ end
144
+ end
145
+ end
146
+
147
+ describe 'with textcaptcha yaml config file' do
148
+
149
+ before(:each) do
150
+ @widget = Widget.new
151
+ end
152
+
153
+ it 'should be configurable from config/textcaptcha.yml file' do
154
+ @widget.textcaptcha_config['api_key'].should eql('8u5ixtdnq9csc84cok0owswgo')
155
+ @widget.textcaptcha_config['bcrypt_salt'].should eql('$2a$10$j0bmycH.SVfD1b5mpEGPpe')
156
+ @widget.textcaptcha_config['bcrypt_cost'].should eql(10)
157
+ @widget.textcaptcha_config['questions'].length.should eql(10)
158
+ end
159
+
160
+ it 'should generate spam question' do
161
+ @widget.generate_spam_question
162
+ @widget.spam_question.should_not be_nil
163
+ @widget.possible_answers.should_not be_nil
164
+ @widget.possible_answers.should be_an(Array)
165
+ @widget.validate.should be_false
166
+ end
167
+
168
+ describe 'and textcaptcha unavailable' do
169
+
170
+ before(:each) do
171
+ Net::HTTP.stub(:get).and_raise(SocketError)
172
+ end
173
+
174
+ it 'should fall back to a random user defined question' do
175
+ @widget.generate_spam_question
176
+ @widget.spam_question.should_not be_nil
177
+ @widget.possible_answers.should_not be_nil
178
+ @widget.possible_answers.should be_an(Array)
179
+ @widget.validate.should be_false
180
+ end
181
+ end
182
+ end
183
+ end
data/spec/database.yml ADDED
@@ -0,0 +1,21 @@
1
+ sqlite:
2
+ :adapter: sqlite
3
+ :database: acts_as_textcaptcha.sqlite.db
4
+
5
+ sqlite3:
6
+ :adapter: sqlite3
7
+ :database: acts_as_textcaptcha.sqlite3.db
8
+
9
+ postgresql:
10
+ :adapter: postgresql
11
+ :username: postgres
12
+ :password: postgres
13
+ :database: acts_as_textcaptcha_test
14
+ :min_messages: ERROR
15
+
16
+ mysql:
17
+ :adapter: mysql
18
+ :host: localhost
19
+ :username: root
20
+ :password:
21
+ :database: acts_as_textcaptcha_test
data/spec/schema.rb ADDED
@@ -0,0 +1,18 @@
1
+ ActiveRecord::Schema.define(:version => 0) do
2
+
3
+ create_table :widgets, :force => true do |t|
4
+ t.string :name
5
+ end
6
+
7
+ create_table :comments, :force => true do |t|
8
+ t.string :name
9
+ end
10
+
11
+ create_table :reviews, :force => true do |t|
12
+ t.string :name
13
+ end
14
+
15
+ create_table :notes, :force => true do |t|
16
+ t.string :name
17
+ end
18
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,2 @@
1
+ -cfs
2
+ --loadby mtime
@@ -0,0 +1,11 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require 'active_record'
4
+
5
+ require 'lib/acts_as_textcaptcha'
6
+ require File.dirname(__FILE__) + '/../init.rb'
7
+
8
+ config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
9
+ ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite3'])
10
+
11
+ load(File.dirname(__FILE__) + "/schema.rb")
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_textcaptcha
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 1
7
+ - 1
8
+ - 0
9
+ version: 1.1.0
10
+ platform: ruby
11
+ authors:
12
+ - Matthew Hutchinson
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+
17
+ date: 2010-04-19 00:00:00 +01:00
18
+ default_executable:
19
+ dependencies: []
20
+
21
+ description: |-
22
+ Spam protection for your ActiveRecord models using logic questions and the excellent textcaptcha api. See textcaptcha.com for more details and to get your api key.
23
+ The logic questions are aimed at a child's age of 7, so can be solved easily by all but the most cognitively impaired users. As they involve human logic, such questions cannot be solved by a robot.
24
+ For more reasons on why logic questions are useful, see here; http://textcaptcha.com/why
25
+ email: matt@hiddenloop.com
26
+ executables: []
27
+
28
+ extensions: []
29
+
30
+ extra_rdoc_files:
31
+ - LICENSE
32
+ - README.rdoc
33
+ files:
34
+ - .gitignore
35
+ - LICENSE
36
+ - README.rdoc
37
+ - Rakefile
38
+ - VERSION
39
+ - config/textcaptcha.yml
40
+ - init.rb
41
+ - lib/acts_as_textcaptcha.rb
42
+ - lib/textcaptcha_helper.rb
43
+ - rails/init.rb
44
+ - spec/acts_as_textcaptcha_spec.rb
45
+ - spec/database.yml
46
+ - spec/schema.rb
47
+ - spec/spec.opts
48
+ - spec/spec_helper.rb
49
+ has_rdoc: true
50
+ homepage: http://github.com/hiddenloop/acts_as_textcaptcha
51
+ licenses: []
52
+
53
+ post_install_message:
54
+ rdoc_options:
55
+ - --charset=UTF-8
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ segments:
63
+ - 0
64
+ version: "0"
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ segments:
70
+ - 0
71
+ version: "0"
72
+ requirements: []
73
+
74
+ rubyforge_project:
75
+ rubygems_version: 1.3.6
76
+ signing_key:
77
+ specification_version: 3
78
+ summary: Spam protection for your models via logic questions and the excellent textcaptcha.com api
79
+ test_files:
80
+ - spec/acts_as_textcaptcha_spec.rb
81
+ - spec/schema.rb
82
+ - spec/spec_helper.rb