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.
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
- gem 'rdoc'
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['test/**/*_test.rb']
19
+ t.test_files = FileList["test/**/*_test.rb"]
11
20
  end
12
21
 
13
- task :default => [:test]
14
-
15
- # rdoc tasks
16
- require 'rdoc/task'
17
- RDoc::Task.new do |rd|
18
- rd.main = "README.md"
19
- rd.title = 'acts_as_textcaptcha'
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']
@@ -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 |s|
7
- s.name = "acts_as_textcaptcha"
8
- s.version = ActsAsTextcaptcha::VERSION
9
- s.authors = ["Matthew Hutchinson"]
10
- s.email = ["matt@hiddenloop.com"]
11
- s.homepage = "http://github.com/matthutchinson/acts_as_textcaptcha"
12
- s.license = 'MIT'
13
- s.summary = %q{Spam protection for your models via logic questions and the textcaptcha.com API}
14
-
15
- s.description = %q{Simple question/answer based spam protection for your Rails models.
16
- You can define your own logic questions and/or fetch questions from the textcaptcha.com API.
17
- The questions involve human logic and are tough for spam bots to crack.
18
- For more reasons on why logic questions are a good idea visit; http://textcaptcha.com/why}
19
-
20
- # Prevent pushing this gem to RubyGems.org. To allow pushes either set the
21
- # 'allowed_push_host' to allow pushing to a single host or delete this section
22
- # to allow pushing to any host.
23
- if s.respond_to?(:metadata)
24
- s.metadata['allowed_push_host'] = "https://rubygems.org"
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
- s.files = `git ls-files`.split("\n")
30
- s.test_files = `git ls-files -- {test}/*`.split("\n")
31
- s.require_paths = ["lib"]
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
- s.required_ruby_version = ">= 2.1"
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
- # always test against latest rails version
36
- s.add_development_dependency('rails', '~> 5.1.4')
55
+ # docs
56
+ spec.add_development_dependency('rdoc')
37
57
 
38
- s.add_development_dependency('mime-types')
39
- s.add_development_dependency('bundler')
40
- s.add_development_dependency('minitest')
41
- s.add_development_dependency('rdoc')
42
- s.add_development_dependency('sqlite3')
43
- s.add_development_dependency('webmock')
44
- s.add_development_dependency('appraisal')
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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+ bundle exec rake rdoc
@@ -4,4 +4,4 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "3.2.22.5"
6
6
 
7
- gemspec path: "../"
7
+ gemspec :path => "../"
@@ -4,4 +4,4 @@ source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "4.2.10"
6
6
 
7
- gemspec path: "../"
7
+ gemspec :path => "../"
@@ -2,6 +2,6 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", "5.1.5"
5
+ gem "rails", "5.2.0"
6
6
 
7
- gemspec path: "../"
7
+ gemspec :path => "../"
@@ -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' if defined?(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 :textcaptcha_question, :textcaptcha_answer, :textcaptcha_key
12
+ attr_accessor :textcaptcha_question, :textcaptcha_answer, :textcaptcha_key
14
13
 
15
- # Rails 3, ensure these attrs are accessible
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
- validate :validate_textcaptcha, :if => :perform_textcaptcha?
19
+ self.textcaptcha_config = build_textcaptcha_config(options).symbolize_keys!
21
20
 
22
- if options.is_a?(Hash)
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
- # by default this will only allow new records to be
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
- question = answers = nil
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
- def textcaptcha_config_questions
70
- if textcaptcha_config[:questions]
71
- random_question = textcaptcha_config[:questions][rand(textcaptcha_config[:questions].size)].symbolize_keys!
72
- [random_question[:question], (random_question[:answers] || '').split(',').map!{ |answer| safe_md5(answer) }]
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
- # check textcaptcha, if incorrect, regenerate a new textcaptcha
78
- def validate_textcaptcha
79
- valid_answers = textcaptcha_cache.read(textcaptcha_key) || []
80
- reset_textcaptcha
81
- if valid_answers.include?(safe_md5(textcaptcha_answer))
82
- # answer was valid, mutate the key again
83
- self.textcaptcha_key = textcaptcha_random_key
84
- textcaptcha_cache.write(textcaptcha_key, valid_answers, textcaptcha_cache_options)
85
- true
86
- else
87
- if valid_answers.empty?
88
- # took too long to answer
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
- def reset_textcaptcha
100
- if textcaptcha_key
101
- textcaptcha_cache.delete(textcaptcha_key)
102
- self.textcaptcha_key = nil
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
- def assign_textcaptcha(question, answers)
107
- self.textcaptcha_question = question
108
- self.textcaptcha_key = textcaptcha_random_key
109
- textcaptcha_cache.write(textcaptcha_key, answers, textcaptcha_cache_options)
110
- end
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
- # strip whitespace pass through mb_chars (a multibyte
113
- # safe proxy for string methods) then downcase
114
- def safe_md5(answer)
115
- Digest::MD5.hexdigest(answer.to_s.strip.mb_chars.downcase)
116
- end
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
- # a random cache key, time based and random
119
- def textcaptcha_random_key
120
- safe_md5(Time.now.to_i + rand(1_000_000))
121
- end
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
- def textcaptcha_cache_options
124
- if textcaptcha_config[:cache_expiry_minutes]
125
- { :expires_in => textcaptcha_config[:cache_expiry_minutes].to_f.minutes }
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
- # cache is used to persist textcaptcha questions and answers
132
- # between requests
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
- # simple wrapper for the textcaptcha.com API service
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
- # raised if an empty response is returned
9
- class EmptyResponseError < StandardError; end;
6
+ BASE_URL = 'http://textcaptcha.com'
10
7
 
11
- class TextcaptchaApi
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
- ENDPOINT = 'http://textcaptcha.com/api/'
19
+ def fetch
20
+ parse(get.to_s)
21
+ end
14
22
 
15
- def self.fetch(api_key, options = {})
16
- begin
17
- url = uri_parser.parse("#{ENDPOINT}#{api_key}")
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
- response = http.get(url.path)
27
- if response.body.to_s.empty?
28
- raise ActsAsTextcaptcha::EmptyResponseError
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
- return parse(response.body)
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, Net::ProtocolError,
35
- URI::InvalidURIError, ActsAsTextcaptcha::EmptyResponseError,
36
- REXML::ParseException
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
- def self.parse(xml)
42
- parsed_xml = ActiveSupport::XmlMini.parse(xml)['captcha']
43
- question = parsed_xml['question']['__content__']
44
- if parsed_xml['answer'].is_a?(Array)
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
- [question, answers]
51
- end
52
-
53
-
54
- private
55
-
56
- def self.uri_parser
57
- # URI.parse is deprecated in 1.9.2
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