acts_as_textcaptcha 4.3.0 → 4.4.1

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