acts_as_textcaptcha 4.4.1 → 4.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +1165 -0
- data/.simplecov +4 -2
- data/.travis.yml +36 -9
- data/Appraisals +14 -6
- data/CHANGELOG.md +35 -3
- data/CODE_OF_CONDUCT.md +1 -2
- data/Gemfile +3 -1
- data/PULL_REQUEST_TEMPLATE.md +16 -0
- data/README.md +42 -14
- data/Rakefile +18 -10
- data/acts_as_textcaptcha.gemspec +35 -38
- data/gemfiles/rails_3.gemfile +2 -1
- data/gemfiles/rails_4.gemfile +3 -2
- data/gemfiles/rails_5.gemfile +3 -2
- data/gemfiles/rails_6.gemfile +8 -0
- data/gemfiles/rails_7.gemfile +8 -0
- data/lib/acts_as_textcaptcha/errors.rb +2 -0
- data/lib/acts_as_textcaptcha/framework/rails.rb +3 -1
- data/lib/acts_as_textcaptcha/railtie.rb +9 -0
- data/lib/{tasks → acts_as_textcaptcha/tasks}/textcaptcha.rake +6 -5
- data/lib/acts_as_textcaptcha/textcaptcha.rb +89 -95
- data/lib/acts_as_textcaptcha/textcaptcha_api.rb +36 -37
- data/lib/acts_as_textcaptcha/textcaptcha_cache.rb +8 -9
- data/lib/acts_as_textcaptcha/textcaptcha_config.rb +37 -36
- data/lib/acts_as_textcaptcha/textcaptcha_helper.rb +12 -13
- data/lib/acts_as_textcaptcha/version.rb +3 -1
- data/lib/acts_as_textcaptcha.rb +9 -6
- metadata +48 -29
@@ -1,20 +1,19 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "yaml"
|
4
|
+
require "net/http"
|
5
|
+
require "digest/md5"
|
6
|
+
require "acts_as_textcaptcha/textcaptcha_cache"
|
7
|
+
require "acts_as_textcaptcha/textcaptcha_api"
|
6
8
|
|
7
9
|
module ActsAsTextcaptcha
|
8
10
|
module Textcaptcha
|
9
|
-
|
10
11
|
def acts_as_textcaptcha(options = nil)
|
11
12
|
cattr_accessor :textcaptcha_config
|
12
13
|
attr_accessor :textcaptcha_question, :textcaptcha_answer, :textcaptcha_key
|
13
14
|
|
14
15
|
# ensure these attrs are accessible (Rails 3)
|
15
|
-
if respond_to?(:accessible_attributes) && respond_to?(:attr_accessible)
|
16
|
-
attr_accessible :textcaptcha_answer, :textcaptcha_key
|
17
|
-
end
|
16
|
+
attr_accessible :textcaptcha_answer, :textcaptcha_key if respond_to?(:accessible_attributes) && respond_to?(:attr_accessible)
|
18
17
|
|
19
18
|
self.textcaptcha_config = build_textcaptcha_config(options).symbolize_keys!
|
20
19
|
|
@@ -24,122 +23,117 @@ module ActsAsTextcaptcha
|
|
24
23
|
end
|
25
24
|
|
26
25
|
module InstanceMethods
|
27
|
-
|
28
26
|
# override this method to toggle textcaptcha checking, by default this
|
29
27
|
# will only allow new records to be protected with textcaptchas
|
30
28
|
def perform_textcaptcha?
|
31
|
-
(!respond_to?(
|
29
|
+
(!respond_to?("new_record?") || new_record?)
|
32
30
|
end
|
33
31
|
|
34
32
|
def textcaptcha
|
35
|
-
if perform_textcaptcha? && textcaptcha_config
|
36
|
-
assign_textcaptcha(fetch_q_and_a || config_q_and_a)
|
37
|
-
end
|
33
|
+
assign_textcaptcha(fetch_q_and_a || config_q_and_a) if perform_textcaptcha? && textcaptcha_config
|
38
34
|
end
|
39
35
|
|
40
|
-
|
41
36
|
private
|
42
37
|
|
43
|
-
|
44
|
-
|
38
|
+
def fetch_q_and_a
|
39
|
+
return unless should_fetch?
|
45
40
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
41
|
+
TextcaptchaApi.new(
|
42
|
+
api_key: textcaptcha_config[:api_key],
|
43
|
+
api_endpoint: textcaptcha_config[:api_endpoint],
|
44
|
+
raise_errors: textcaptcha_config[:raise_errors]
|
45
|
+
).fetch
|
46
|
+
end
|
52
47
|
|
53
|
-
|
54
|
-
|
55
|
-
|
48
|
+
def should_fetch?
|
49
|
+
textcaptcha_config[:api_key] || textcaptcha_config[:api_endpoint]
|
50
|
+
end
|
56
51
|
|
57
|
-
|
58
|
-
|
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
|
52
|
+
def config_q_and_a
|
53
|
+
return unless textcaptcha_config[:questions]
|
66
54
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
55
|
+
random_question = textcaptcha_config[:questions][rand(textcaptcha_config[:questions].size)].symbolize_keys!
|
56
|
+
answers = (random_question[:answers] || "").split(",").map { |answer| safe_md5(answer) }
|
57
|
+
{ "q" => random_question[:question], "a" => answers } if random_question && answers.present?
|
58
|
+
end
|
82
59
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
60
|
+
# check textcaptcha, if incorrect, generate a new textcaptcha
|
61
|
+
def validate_textcaptcha
|
62
|
+
valid_answers = textcaptcha_cache.read(textcaptcha_key) || []
|
63
|
+
reset_textcaptcha
|
64
|
+
if valid_answers.include?(safe_md5(textcaptcha_answer))
|
65
|
+
# answer was valid, mutate the key again
|
66
|
+
self.textcaptcha_key = textcaptcha_random_key
|
67
|
+
textcaptcha_cache.write(textcaptcha_key, valid_answers, textcaptcha_cache_options)
|
68
|
+
true
|
69
|
+
else
|
70
|
+
add_textcaptcha_error(too_slow: valid_answers.empty?)
|
71
|
+
textcaptcha
|
72
|
+
false
|
89
73
|
end
|
74
|
+
end
|
90
75
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
76
|
+
def add_textcaptcha_error(too_slow: false)
|
77
|
+
if too_slow
|
78
|
+
errors.add(:textcaptcha_answer, :expired, message: "was not submitted quickly enough, try another question instead")
|
79
|
+
else
|
80
|
+
errors.add(:textcaptcha_answer, :incorrect, message: "is incorrect, try another question instead")
|
96
81
|
end
|
82
|
+
end
|
97
83
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
self.textcaptcha_key =
|
102
|
-
textcaptcha_cache.write(textcaptcha_key, q_and_a['a'], textcaptcha_cache_options)
|
84
|
+
def reset_textcaptcha
|
85
|
+
if textcaptcha_key
|
86
|
+
textcaptcha_cache.delete(textcaptcha_key)
|
87
|
+
self.textcaptcha_key = nil
|
103
88
|
end
|
89
|
+
end
|
104
90
|
|
105
|
-
|
106
|
-
|
107
|
-
def safe_md5(answer)
|
108
|
-
Digest::MD5.hexdigest(answer.to_s.strip.mb_chars.downcase)
|
109
|
-
end
|
91
|
+
def assign_textcaptcha(q_and_a)
|
92
|
+
return unless q_and_a
|
110
93
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
94
|
+
self.textcaptcha_question = q_and_a["q"]
|
95
|
+
self.textcaptcha_key = textcaptcha_random_key
|
96
|
+
textcaptcha_cache.write(textcaptcha_key, q_and_a["a"], textcaptcha_cache_options)
|
97
|
+
end
|
115
98
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
99
|
+
# strip whitespace pass through mb_chars (a multibyte safe proxy for
|
100
|
+
# strings) then downcase
|
101
|
+
def safe_md5(answer)
|
102
|
+
Digest::MD5.hexdigest(answer.to_s.strip.mb_chars.downcase)
|
103
|
+
end
|
104
|
+
|
105
|
+
# a random cache key, time based, random
|
106
|
+
def textcaptcha_random_key
|
107
|
+
safe_md5(Time.now.to_i + rand(1_000_000))
|
108
|
+
end
|
123
109
|
|
124
|
-
|
125
|
-
|
110
|
+
def textcaptcha_cache_options
|
111
|
+
if textcaptcha_config[:cache_expiry_minutes]
|
112
|
+
{ expires_in: textcaptcha_config[:cache_expiry_minutes].to_f.minutes }
|
113
|
+
else
|
114
|
+
{}
|
126
115
|
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def textcaptcha_cache
|
119
|
+
@textcaptcha_cache ||= TextcaptchaCache.new
|
120
|
+
end
|
127
121
|
end
|
128
122
|
|
129
123
|
private
|
130
124
|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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')
|
125
|
+
def build_textcaptcha_config(options)
|
126
|
+
if options.is_a?(Hash)
|
127
|
+
options
|
128
|
+
else
|
129
|
+
YAML.safe_load(ERB.new(read_textcaptcha_config).result, aliases: true)[Rails.env]
|
139
130
|
end
|
131
|
+
rescue StandardError
|
132
|
+
raise ArgumentError, "could not find any textcaptcha options, in config/textcaptcha.yml or model - run rake textcaptcha:config to generate a template config file"
|
133
|
+
end
|
140
134
|
|
141
|
-
|
142
|
-
|
143
|
-
|
135
|
+
def read_textcaptcha_config
|
136
|
+
File.read("#{Rails.root || "."}/config/textcaptcha.yml")
|
137
|
+
end
|
144
138
|
end
|
145
139
|
end
|
@@ -1,19 +1,20 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
2
4
|
|
3
5
|
module ActsAsTextcaptcha
|
4
6
|
class TextcaptchaApi
|
5
|
-
|
6
|
-
BASE_URL = 'http://textcaptcha.com'
|
7
|
+
BASE_URL = "http://textcaptcha.com"
|
7
8
|
|
8
9
|
def initialize(api_key: nil, api_endpoint: nil, raise_errors: false)
|
9
|
-
if api_endpoint
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
10
|
+
self.uri = if api_endpoint
|
11
|
+
URI(api_endpoint)
|
12
|
+
else
|
13
|
+
URI("#{BASE_URL}/#{api_key}.json")
|
14
|
+
end
|
14
15
|
self.raise_errors = raise_errors || false
|
15
|
-
rescue URI::InvalidURIError =>
|
16
|
-
raise ApiKeyError.new(api_key,
|
16
|
+
rescue URI::InvalidURIError => e
|
17
|
+
raise ApiKeyError.new(api_key, e)
|
17
18
|
end
|
18
19
|
|
19
20
|
def fetch
|
@@ -22,35 +23,33 @@ module ActsAsTextcaptcha
|
|
22
23
|
|
23
24
|
private
|
24
25
|
|
25
|
-
|
26
|
-
|
27
|
-
def get
|
28
|
-
response = Net::HTTP.new(uri.host, uri.port).get(uri.path)
|
29
|
-
if response.code == '200'
|
30
|
-
response.body
|
31
|
-
else
|
32
|
-
handle_error ResponseError.new(uri, "status: #{response.code}")
|
33
|
-
end
|
34
|
-
rescue SocketError, Timeout::Error, Errno::EINVAL, Errno::ECONNRESET,
|
35
|
-
Errno::EHOSTUNREACH, EOFError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
|
36
|
-
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
37
|
-
Net::ProtocolError => exception
|
38
|
-
handle_error ResponseError.new(uri, exception)
|
39
|
-
end
|
26
|
+
attr_accessor :uri, :raise_errors
|
40
27
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
28
|
+
def get
|
29
|
+
response = Net::HTTP.new(uri.host, uri.port).get(uri.path)
|
30
|
+
if response.code == "200"
|
31
|
+
response.body
|
32
|
+
else
|
33
|
+
handle_error ResponseError.new(uri, "status: #{response.code}")
|
45
34
|
end
|
35
|
+
rescue SocketError, Timeout::Error, Errno::EINVAL, Errno::ECONNRESET,
|
36
|
+
Errno::EHOSTUNREACH, EOFError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
|
37
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
38
|
+
Net::ProtocolError => e
|
39
|
+
handle_error ResponseError.new(uri, e)
|
40
|
+
end
|
46
41
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
42
|
+
def parse(response)
|
43
|
+
JSON.parse(response) unless response.empty?
|
44
|
+
rescue JSON::ParserError
|
45
|
+
handle_error ParseError.new(uri)
|
46
|
+
end
|
47
|
+
|
48
|
+
def handle_error(error)
|
49
|
+
raise error if raise_errors
|
50
|
+
|
51
|
+
Rails.logger.error("#{error.class} #{error.message}")
|
52
|
+
nil
|
53
|
+
end
|
55
54
|
end
|
56
55
|
end
|
@@ -1,16 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# A simple cache for storing Textcaptcha answers, Rails.cache is used as the
|
2
|
-
# backend (ActiveSupport::Cache)
|
4
|
+
# backend (ActiveSupport::Cache). This must not be set as a `:null_store`.
|
3
5
|
|
4
6
|
module ActsAsTextcaptcha
|
5
7
|
class TextcaptchaCache
|
6
|
-
|
7
|
-
KEY_PREFIX = 'acts_as_textcaptcha-'
|
8
|
+
KEY_PREFIX = "acts_as_textcaptcha-"
|
8
9
|
DEFAULT_EXPIRY_MINUTES = 10
|
9
10
|
|
10
11
|
def write(key, value, options = {})
|
11
|
-
unless options.has_key?(:expires_in)
|
12
|
-
options[:expires_in] = DEFAULT_EXPIRY_MINUTES.minutes
|
13
|
-
end
|
12
|
+
options[:expires_in] = DEFAULT_EXPIRY_MINUTES.minutes unless options.has_key?(:expires_in)
|
14
13
|
Rails.cache.write(cache_key(key), value, options)
|
15
14
|
end
|
16
15
|
|
@@ -24,8 +23,8 @@ module ActsAsTextcaptcha
|
|
24
23
|
|
25
24
|
private
|
26
25
|
|
27
|
-
|
28
|
-
|
29
|
-
|
26
|
+
def cache_key(key)
|
27
|
+
"#{KEY_PREFIX}#{key}"
|
28
|
+
end
|
30
29
|
end
|
31
30
|
end
|
@@ -1,46 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActsAsTextcaptcha
|
2
4
|
class TextcaptchaConfig
|
5
|
+
YAML = <<~CONFIG
|
6
|
+
development: &common_settings
|
7
|
+
api_key: 'TEXTCAPTCHA_API_IDENT' # see https://textcaptcha.com for details
|
8
|
+
# api_endpoint: nil # Optional API URL to fetch questions and answers from
|
9
|
+
# raise_errors: false # Optional flag, if true errors will be raised if the API endpoint fails
|
10
|
+
# cache_expiry_minutes: 10 # Optional minutes for captcha answers to persist in the cache (default 10 minutes)
|
3
11
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
answers: '10,ten'
|
26
|
-
- question: 'is boiling water hot or cold?'
|
27
|
-
answers: 'hot'
|
28
|
-
- question: 'what color is my blue shirt today?'
|
29
|
-
answers: 'blue'
|
30
|
-
- question: 'what is 16 plus 4?'
|
31
|
-
answers: '20,twenty'
|
12
|
+
questions:
|
13
|
+
- question: 'Is ice hot or cold?'
|
14
|
+
answers: 'cold'
|
15
|
+
- question: 'what color is an orange?'
|
16
|
+
answers: 'orange'
|
17
|
+
- question: 'what is two plus 3?'
|
18
|
+
answers: '5,five'
|
19
|
+
- question: 'what is 5 times two?'
|
20
|
+
answers: '10,ten'
|
21
|
+
- question: 'How many colors in the list, green, brown, foot and blue?'
|
22
|
+
answers: '3,three'
|
23
|
+
- question: 'what is Georges name?'
|
24
|
+
answers: 'george'
|
25
|
+
- question: '11 minus 1?'
|
26
|
+
answers: '10,ten'
|
27
|
+
- question: 'is boiling water hot or cold?'
|
28
|
+
answers: 'hot'
|
29
|
+
- question: 'what color is my blue shirt today?'
|
30
|
+
answers: 'blue'
|
31
|
+
- question: 'what is 16 plus 4?'
|
32
|
+
answers: '20,twenty'
|
32
33
|
|
33
|
-
test:
|
34
|
-
|
35
|
-
|
34
|
+
test:
|
35
|
+
<<: *common_settings
|
36
|
+
api_key: 'TEST_TEXTCAPTCHA_API_IDENT'
|
36
37
|
|
37
|
-
production:
|
38
|
-
|
39
|
-
CONFIG
|
38
|
+
production:
|
39
|
+
<<: *common_settings
|
40
|
+
CONFIG
|
40
41
|
|
41
|
-
def self.create(path:
|
42
|
+
def self.create(path: "./config/textcaptcha.yml")
|
42
43
|
FileUtils.mkdir_p(File.dirname(path))
|
43
|
-
File.
|
44
|
+
File.write(path, YAML)
|
44
45
|
end
|
45
46
|
end
|
46
47
|
end
|
@@ -1,22 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module ActsAsTextcaptcha
|
2
4
|
module TextcaptchaHelper
|
3
|
-
|
4
|
-
|
5
|
-
if f.object.perform_textcaptcha? && f.object.textcaptcha_key
|
6
|
-
build_textcaptcha_form_elements(f, &block)
|
7
|
-
end
|
5
|
+
def textcaptcha_fields(form, &block)
|
6
|
+
build_textcaptcha_form_elements(form, &block) if form.object.perform_textcaptcha? && form.object.textcaptcha_key
|
8
7
|
end
|
9
8
|
|
10
9
|
private
|
11
10
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
end
|
19
|
-
captcha_html.html_safe
|
11
|
+
def build_textcaptcha_form_elements(form, &block)
|
12
|
+
captcha_html = form.hidden_field(:textcaptcha_key)
|
13
|
+
if form.object.textcaptcha_question
|
14
|
+
captcha_html += capture(&block)
|
15
|
+
elsif form.object.textcaptcha_answer
|
16
|
+
captcha_html += form.hidden_field(:textcaptcha_answer)
|
20
17
|
end
|
18
|
+
captcha_html.html_safe
|
19
|
+
end
|
21
20
|
end
|
22
21
|
end
|
data/lib/acts_as_textcaptcha.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "acts_as_textcaptcha/version"
|
4
|
+
require "acts_as_textcaptcha/errors"
|
5
|
+
require "acts_as_textcaptcha/textcaptcha"
|
6
|
+
require "acts_as_textcaptcha/textcaptcha_config"
|
7
|
+
require "acts_as_textcaptcha/textcaptcha_helper"
|
8
|
+
require "acts_as_textcaptcha/framework/rails"
|
9
|
+
require "acts_as_textcaptcha/railtie" if defined?(Rails::Railtie)
|