acts_as_textcaptcha 4.3.0 → 4.4.1
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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.simplecov +9 -0
- data/.travis.yml +13 -4
- data/Appraisals +1 -1
- data/CHANGELOG.md +68 -34
- data/CODE_OF_CONDUCT.md +54 -30
- data/CONTRIBUTING.md +14 -9
- data/Gemfile +0 -2
- data/LICENSE +165 -0
- data/README.md +194 -223
- data/Rakefile +21 -13
- data/acts_as_textcaptcha.gemspec +52 -32
- data/bin/console +2 -5
- data/bin/setup +7 -0
- data/gemfiles/rails_3.gemfile +1 -1
- data/gemfiles/rails_4.gemfile +1 -1
- data/gemfiles/rails_5.gemfile +2 -2
- data/lib/acts_as_textcaptcha.rb +3 -1
- data/lib/acts_as_textcaptcha/errors.rb +19 -0
- data/lib/acts_as_textcaptcha/textcaptcha.rb +91 -84
- data/lib/acts_as_textcaptcha/textcaptcha_api.rb +39 -44
- data/lib/acts_as_textcaptcha/textcaptcha_cache.rb +12 -15
- data/{config/textcaptcha.yml → lib/acts_as_textcaptcha/textcaptcha_config.rb} +16 -4
- data/lib/acts_as_textcaptcha/textcaptcha_helper.rb +14 -14
- data/lib/acts_as_textcaptcha/version.rb +1 -1
- data/lib/tasks/textcaptcha.rake +9 -14
- metadata +64 -31
- data/.coveralls.yml +0 -1
- data/LICENSE.txt +0 -21
- data/test/schema.rb +0 -34
- data/test/test_helper.rb +0 -28
- data/test/test_models.rb +0 -69
- data/test/textcaptcha_api_test.rb +0 -46
- data/test/textcaptcha_cache_test.rb +0 -25
- data/test/textcaptcha_helper_test.rb +0 -68
- data/test/textcaptcha_test.rb +0 -198
data/Rakefile
CHANGED
@@ -1,23 +1,31 @@
|
|
1
|
-
require 'rubygems'
|
2
1
|
require 'bundler/gem_tasks'
|
3
2
|
require 'rake/testtask'
|
3
|
+
require "rdoc/task"
|
4
|
+
load './lib/tasks/textcaptcha.rake'
|
4
5
|
|
5
|
-
|
6
|
+
# generate docs
|
7
|
+
RDoc::Task.new do |rd|
|
8
|
+
rd.main = "README.md"
|
9
|
+
rd.title = 'ActsAsTextcaptcha'
|
10
|
+
rd.rdoc_dir = 'doc'
|
11
|
+
rd.options << "--all"
|
12
|
+
rd.rdoc_files.include("README.md", "LICENSE", "lib/**/*.rb")
|
13
|
+
end
|
6
14
|
|
15
|
+
# run tests
|
7
16
|
Rake::TestTask.new(:test) do |t|
|
8
17
|
t.libs << "test"
|
9
18
|
t.libs << "lib"
|
10
|
-
t.test_files = FileList[
|
19
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
11
20
|
end
|
12
21
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
rd.rdoc_dir = 'doc'
|
21
|
-
rd.options << "--all"
|
22
|
-
rd.rdoc_files.include("README.md", "LICENSE.txt", "lib/**/*.rb")
|
22
|
+
# run tests with code coverage (default)
|
23
|
+
namespace :test do
|
24
|
+
desc "Run all tests and features and generate a code coverage report"
|
25
|
+
task :coverage do
|
26
|
+
ENV['COVERAGE'] = 'true'
|
27
|
+
Rake::Task['test'].execute
|
28
|
+
end
|
23
29
|
end
|
30
|
+
|
31
|
+
task :default => ['test:coverage']
|
data/acts_as_textcaptcha.gemspec
CHANGED
@@ -3,43 +3,63 @@ lib = File.expand_path('../lib', __FILE__)
|
|
3
3
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
4
|
require "acts_as_textcaptcha/version"
|
5
5
|
|
6
|
-
Gem::Specification.new do |
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "acts_as_textcaptcha"
|
8
|
+
spec.version = ActsAsTextcaptcha::VERSION
|
9
|
+
spec.authors = ["Matthew Hutchinson"]
|
10
|
+
spec.email = ["matt@hiddenloop.com"]
|
11
|
+
spec.homepage = "http://github.com/matthutchinson/acts_as_textcaptcha"
|
12
|
+
spec.license = 'MIT'
|
13
|
+
spec.summary = "A text-based logic question captcha for Rails"
|
14
|
+
|
15
|
+
spec.description = <<-EOF
|
16
|
+
ActsAsTextcaptcha provides spam protection for Rails models with a text-based
|
17
|
+
logic question captcha. Questions are fetched from Rob Tuley's textcaptcha.com
|
18
|
+
They can be solved easily by humans but are tough for robots to crack. For
|
19
|
+
reasons on why logic based captchas are a good idea visit textcaptcha.com
|
20
|
+
EOF
|
21
|
+
|
22
|
+
spec.metadata = {
|
23
|
+
"homepage_uri" => "https://github.com/matthutchinson/acts_as_textcaptcha",
|
24
|
+
"changelog_uri" => "https://github.com/matthutchinson/acts_as_textcaptcha/blob/master/CHANGELOG.md",
|
25
|
+
"source_code_uri" => "https://github.com/matthutchinson/acts_as_textcaptcha",
|
26
|
+
"bug_tracker_uri" => "https://github.com/matthutchinson/acts_as_textcaptcha/issues",
|
27
|
+
}
|
28
|
+
|
29
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
|
30
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
31
|
+
if spec.respond_to?(:metadata)
|
32
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
25
33
|
else
|
26
34
|
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
27
35
|
end
|
28
36
|
|
29
|
-
|
30
|
-
|
31
|
-
|
37
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
38
|
+
spec.test_files = `git ls-files -- {test}/*`.split("\n")
|
39
|
+
spec.bindir = "bin"
|
40
|
+
spec.require_paths = ["lib"]
|
41
|
+
|
42
|
+
# documentation
|
43
|
+
spec.has_rdoc = true
|
44
|
+
spec.extra_rdoc_files = ["README.md", "LICENSE"]
|
45
|
+
spec.rdoc_options << "--title" << "ActAsTextcaptcha" << "--main" << "README.md" << "-ri"
|
46
|
+
|
47
|
+
# non-gem dependecies
|
48
|
+
spec.required_ruby_version = ">= 2.1"
|
32
49
|
|
33
|
-
|
50
|
+
# dev gems
|
51
|
+
spec.add_development_dependency('bundler')
|
52
|
+
spec.add_development_dependency "rake"
|
53
|
+
spec.add_development_dependency('pry-byebug')
|
34
54
|
|
35
|
-
#
|
36
|
-
|
55
|
+
# docs
|
56
|
+
spec.add_development_dependency('rdoc')
|
37
57
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
58
|
+
# testing
|
59
|
+
spec.add_development_dependency('minitest')
|
60
|
+
spec.add_development_dependency('rails', '~> 5.2.0')
|
61
|
+
spec.add_development_dependency('sqlite3')
|
62
|
+
spec.add_development_dependency('webmock')
|
63
|
+
spec.add_development_dependency('simplecov')
|
64
|
+
spec.add_development_dependency('appraisal')
|
45
65
|
end
|
data/bin/console
CHANGED
@@ -1,11 +1,8 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
3
|
require "bundler/setup"
|
4
|
+
require "rails/all"
|
4
5
|
require "acts_as_textcaptcha"
|
5
|
-
|
6
|
-
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
-
# with your gem easier. You can also use a different console, if you like.
|
8
|
-
|
9
|
-
# start irb
|
10
6
|
require "irb"
|
7
|
+
|
11
8
|
IRB.start
|
data/bin/setup
ADDED
data/gemfiles/rails_3.gemfile
CHANGED
data/gemfiles/rails_4.gemfile
CHANGED
data/gemfiles/rails_5.gemfile
CHANGED
data/lib/acts_as_textcaptcha.rb
CHANGED
@@ -1,4 +1,6 @@
|
|
1
1
|
require 'acts_as_textcaptcha/version'
|
2
|
+
require 'acts_as_textcaptcha/errors'
|
2
3
|
require 'acts_as_textcaptcha/textcaptcha'
|
4
|
+
require 'acts_as_textcaptcha/textcaptcha_config'
|
3
5
|
require 'acts_as_textcaptcha/textcaptcha_helper'
|
4
|
-
require 'acts_as_textcaptcha/framework/rails'
|
6
|
+
require 'acts_as_textcaptcha/framework/rails'
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ActsAsTextcaptcha
|
2
|
+
class ResponseError < StandardError
|
3
|
+
def initialize(url, exception)
|
4
|
+
super("fetching '#{url}' failed - #{exception}")
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class ApiKeyError < StandardError
|
9
|
+
def initialize(api_key, exception)
|
10
|
+
super("Api key '#{api_key}' is invalid - #{exception}")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class ParseError < StandardError
|
15
|
+
def initialize(url)
|
16
|
+
super("parsing JSON from '#{url}' failed")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -5,134 +5,141 @@ require 'acts_as_textcaptcha/textcaptcha_cache'
|
|
5
5
|
require 'acts_as_textcaptcha/textcaptcha_api'
|
6
6
|
|
7
7
|
module ActsAsTextcaptcha
|
8
|
-
|
9
|
-
module Textcaptcha #:nodoc:
|
8
|
+
module Textcaptcha
|
10
9
|
|
11
10
|
def acts_as_textcaptcha(options = nil)
|
12
11
|
cattr_accessor :textcaptcha_config
|
13
|
-
attr_accessor
|
12
|
+
attr_accessor :textcaptcha_question, :textcaptcha_answer, :textcaptcha_key
|
14
13
|
|
15
|
-
#
|
14
|
+
# ensure these attrs are accessible (Rails 3)
|
16
15
|
if respond_to?(:accessible_attributes) && respond_to?(:attr_accessible)
|
17
16
|
attr_accessible :textcaptcha_answer, :textcaptcha_key
|
18
17
|
end
|
19
18
|
|
20
|
-
|
19
|
+
self.textcaptcha_config = build_textcaptcha_config(options).symbolize_keys!
|
21
20
|
|
22
|
-
if
|
23
|
-
self.textcaptcha_config = options.symbolize_keys!
|
24
|
-
else
|
25
|
-
begin
|
26
|
-
self.textcaptcha_config = YAML.load(File.read("#{Rails.root ? Rails.root.to_s : '.'}/config/textcaptcha.yml"))[Rails.env].symbolize_keys!
|
27
|
-
rescue
|
28
|
-
raise ArgumentError.new('could not find any textcaptcha options, in config/textcaptcha.yml or model - run rake textcaptcha:config to generate a template config file')
|
29
|
-
end
|
30
|
-
end
|
21
|
+
validate :validate_textcaptcha, if: :perform_textcaptcha?
|
31
22
|
|
32
23
|
include InstanceMethods
|
33
24
|
end
|
34
25
|
|
35
|
-
|
36
26
|
module InstanceMethods
|
37
27
|
|
38
|
-
# override this method to toggle textcaptcha checking
|
39
|
-
#
|
40
|
-
# protected with textcaptchas
|
28
|
+
# override this method to toggle textcaptcha checking, by default this
|
29
|
+
# will only allow new records to be protected with textcaptchas
|
41
30
|
def perform_textcaptcha?
|
42
|
-
!respond_to?('new_record?') || new_record?
|
31
|
+
(!respond_to?('new_record?') || new_record?)
|
43
32
|
end
|
44
33
|
|
45
|
-
# generate and assign textcaptcha
|
46
34
|
def textcaptcha
|
47
35
|
if perform_textcaptcha? && textcaptcha_config
|
48
|
-
|
49
|
-
|
50
|
-
# get textcaptcha from api
|
51
|
-
if textcaptcha_config[:api_key]
|
52
|
-
question, answers = TextcaptchaApi.fetch(textcaptcha_config[:api_key], textcaptcha_config)
|
53
|
-
end
|
54
|
-
|
55
|
-
# fall back to config based textcaptcha
|
56
|
-
unless question && answers
|
57
|
-
question, answers = textcaptcha_config_questions
|
58
|
-
end
|
59
|
-
|
60
|
-
if question && answers
|
61
|
-
assign_textcaptcha(question, answers)
|
62
|
-
end
|
36
|
+
assign_textcaptcha(fetch_q_and_a || config_q_and_a)
|
63
37
|
end
|
64
38
|
end
|
65
39
|
|
66
40
|
|
67
41
|
private
|
68
42
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
43
|
+
def fetch_q_and_a
|
44
|
+
return unless should_fetch?
|
45
|
+
|
46
|
+
TextcaptchaApi.new(
|
47
|
+
api_key: textcaptcha_config[:api_key],
|
48
|
+
api_endpoint: textcaptcha_config[:api_endpoint],
|
49
|
+
raise_errors: textcaptcha_config[:raise_errors]
|
50
|
+
).fetch
|
73
51
|
end
|
74
|
-
end
|
75
52
|
|
53
|
+
def should_fetch?
|
54
|
+
textcaptcha_config[:api_key] || textcaptcha_config[:api_endpoint]
|
55
|
+
end
|
76
56
|
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
57
|
+
def config_q_and_a
|
58
|
+
if textcaptcha_config[:questions]
|
59
|
+
random_question = textcaptcha_config[:questions][rand(textcaptcha_config[:questions].size)].symbolize_keys!
|
60
|
+
answers = (random_question[:answers] || '').split(',').map!{ |answer| safe_md5(answer) }
|
61
|
+
if random_question && answers.present?
|
62
|
+
{ 'q' => random_question[:question], 'a' => answers }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# check textcaptcha, if incorrect, generate a new textcaptcha
|
68
|
+
def validate_textcaptcha
|
69
|
+
valid_answers = textcaptcha_cache.read(textcaptcha_key) || []
|
70
|
+
reset_textcaptcha
|
71
|
+
if valid_answers.include?(safe_md5(textcaptcha_answer))
|
72
|
+
# answer was valid, mutate the key again
|
73
|
+
self.textcaptcha_key = textcaptcha_random_key
|
74
|
+
textcaptcha_cache.write(textcaptcha_key, valid_answers, textcaptcha_cache_options)
|
75
|
+
true
|
76
|
+
else
|
77
|
+
add_textcaptcha_error(too_slow: valid_answers.empty?)
|
78
|
+
textcaptcha
|
79
|
+
false
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def add_textcaptcha_error(too_slow: false)
|
84
|
+
if too_slow
|
89
85
|
errors.add(:textcaptcha_answer, :expired, :message => 'was not submitted quickly enough, try another question instead')
|
90
86
|
else
|
91
|
-
# incorrect answer
|
92
87
|
errors.add(:textcaptcha_answer, :incorrect, :message => 'is incorrect, try another question instead')
|
93
88
|
end
|
94
|
-
textcaptcha
|
95
|
-
false
|
96
89
|
end
|
97
|
-
end
|
98
90
|
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
91
|
+
def reset_textcaptcha
|
92
|
+
if textcaptcha_key
|
93
|
+
textcaptcha_cache.delete(textcaptcha_key)
|
94
|
+
self.textcaptcha_key = nil
|
95
|
+
end
|
103
96
|
end
|
104
|
-
end
|
105
97
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
98
|
+
def assign_textcaptcha(q_and_a)
|
99
|
+
return unless q_and_a
|
100
|
+
self.textcaptcha_question = q_and_a['q']
|
101
|
+
self.textcaptcha_key = textcaptcha_random_key
|
102
|
+
textcaptcha_cache.write(textcaptcha_key, q_and_a['a'], textcaptcha_cache_options)
|
103
|
+
end
|
111
104
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
105
|
+
# strip whitespace pass through mb_chars (a multibyte safe proxy for
|
106
|
+
# strings) then downcase
|
107
|
+
def safe_md5(answer)
|
108
|
+
Digest::MD5.hexdigest(answer.to_s.strip.mb_chars.downcase)
|
109
|
+
end
|
117
110
|
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
111
|
+
# a random cache key, time based, random
|
112
|
+
def textcaptcha_random_key
|
113
|
+
safe_md5(Time.now.to_i + rand(1_000_000))
|
114
|
+
end
|
115
|
+
|
116
|
+
def textcaptcha_cache_options
|
117
|
+
if textcaptcha_config[:cache_expiry_minutes]
|
118
|
+
{ :expires_in => textcaptcha_config[:cache_expiry_minutes].to_f.minutes }
|
119
|
+
else
|
120
|
+
{}
|
121
|
+
end
|
122
|
+
end
|
122
123
|
|
123
|
-
|
124
|
-
|
125
|
-
|
124
|
+
def textcaptcha_cache
|
125
|
+
@@textcaptcha_cache ||= TextcaptchaCache.new
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
def build_textcaptcha_config(options)
|
132
|
+
if options.is_a?(Hash)
|
133
|
+
options
|
126
134
|
else
|
127
|
-
|
135
|
+
YAML.load(ERB.new(read_textcaptcha_config).result)[Rails.env]
|
128
136
|
end
|
137
|
+
rescue
|
138
|
+
raise ArgumentError.new('could not find any textcaptcha options, in config/textcaptcha.yml or model - run rake textcaptcha:config to generate a template config file')
|
129
139
|
end
|
130
140
|
|
131
|
-
|
132
|
-
|
133
|
-
def textcaptcha_cache
|
134
|
-
@@textcaptcha_cache ||= TextcaptchaCache.new
|
141
|
+
def read_textcaptcha_config
|
142
|
+
File.read("#{Rails.root ? Rails.root : '.'}/config/textcaptcha.yml")
|
135
143
|
end
|
136
|
-
end
|
137
144
|
end
|
138
145
|
end
|
@@ -1,61 +1,56 @@
|
|
1
|
-
|
2
|
-
# loads and parses captcha question and answers
|
3
|
-
|
4
|
-
require 'rexml/document'
|
1
|
+
require 'json'
|
5
2
|
|
6
3
|
module ActsAsTextcaptcha
|
4
|
+
class TextcaptchaApi
|
7
5
|
|
8
|
-
|
9
|
-
class EmptyResponseError < StandardError; end;
|
6
|
+
BASE_URL = 'http://textcaptcha.com'
|
10
7
|
|
11
|
-
|
8
|
+
def initialize(api_key: nil, api_endpoint: nil, raise_errors: false)
|
9
|
+
if api_endpoint
|
10
|
+
self.uri = URI(api_endpoint)
|
11
|
+
else
|
12
|
+
self.uri = URI("#{BASE_URL}/#{api_key}.json")
|
13
|
+
end
|
14
|
+
self.raise_errors = raise_errors || false
|
15
|
+
rescue URI::InvalidURIError => exception
|
16
|
+
raise ApiKeyError.new(api_key, exception)
|
17
|
+
end
|
12
18
|
|
13
|
-
|
19
|
+
def fetch
|
20
|
+
parse(get.to_s)
|
21
|
+
end
|
14
22
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
http = Net::HTTP.new(url.host, url.port)
|
19
|
-
if options[:http_open_timeout]
|
20
|
-
http.open_timeout = options[:http_open_timeout]
|
21
|
-
end
|
22
|
-
if options[:http_read_timeout]
|
23
|
-
http.read_timeout = options[:http_read_timeout]
|
24
|
-
end
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_accessor :uri, :raise_errors
|
25
26
|
|
26
|
-
|
27
|
-
|
28
|
-
|
27
|
+
def get
|
28
|
+
response = Net::HTTP.new(uri.host, uri.port).get(uri.path)
|
29
|
+
if response.code == '200'
|
30
|
+
response.body
|
29
31
|
else
|
30
|
-
|
32
|
+
handle_error ResponseError.new(uri, "status: #{response.code}")
|
31
33
|
end
|
32
34
|
rescue SocketError, Timeout::Error, Errno::EINVAL, Errno::ECONNRESET,
|
33
35
|
Errno::EHOSTUNREACH, EOFError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
|
34
|
-
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
35
|
-
|
36
|
-
|
37
|
-
# rescue from these errors and continue
|
36
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
37
|
+
Net::ProtocolError => exception
|
38
|
+
handle_error ResponseError.new(uri, exception)
|
38
39
|
end
|
39
|
-
end
|
40
40
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
answers = parsed_xml['answer'].collect { |a| a['__content__'] }
|
46
|
-
else
|
47
|
-
answers = [parsed_xml['answer']['__content__']]
|
41
|
+
def parse(response)
|
42
|
+
JSON.parse(response) unless response.empty?
|
43
|
+
rescue JSON::ParserError
|
44
|
+
handle_error ParseError.new(uri)
|
48
45
|
end
|
49
46
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
URI.const_defined?(:Parser) ? URI::Parser.new : URI
|
59
|
-
end
|
47
|
+
def handle_error(error)
|
48
|
+
if raise_errors
|
49
|
+
raise error
|
50
|
+
else
|
51
|
+
Rails.logger.error("#{error.class} #{error.message}")
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
end
|
60
55
|
end
|
61
56
|
end
|