acts_as_textcaptcha 4.4.1 → 4.6.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.
- 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)
|