acts_as_textcaptcha 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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