require 'yaml' require 'net/http' require 'digest/md5' require 'acts_as_textcaptcha/textcaptcha_cache' require 'acts_as_textcaptcha/textcaptcha_api' module ActsAsTextcaptcha module Textcaptcha def acts_as_textcaptcha(options = nil) cattr_accessor :textcaptcha_config attr_accessor :textcaptcha_question, :textcaptcha_answer, :textcaptcha_key # ensure these attrs are accessible (Rails 3) if respond_to?(:accessible_attributes) && respond_to?(:attr_accessible) attr_accessible :textcaptcha_answer, :textcaptcha_key end self.textcaptcha_config = build_textcaptcha_config(options).symbolize_keys! validate :validate_textcaptcha, if: :perform_textcaptcha? include InstanceMethods end module InstanceMethods # override this method to toggle textcaptcha checking, by default this # will only allow new records to be protected with textcaptchas def perform_textcaptcha? (!respond_to?('new_record?') || new_record?) end def textcaptcha if perform_textcaptcha? && textcaptcha_config assign_textcaptcha(fetch_q_and_a || config_q_and_a) end end private def fetch_q_and_a return unless should_fetch? TextcaptchaApi.new( api_key: textcaptcha_config[:api_key], api_endpoint: textcaptcha_config[:api_endpoint], raise_errors: textcaptcha_config[:raise_errors] ).fetch end def should_fetch? textcaptcha_config[:api_key] || textcaptcha_config[:api_endpoint] end def config_q_and_a if textcaptcha_config[:questions] random_question = textcaptcha_config[:questions][rand(textcaptcha_config[:questions].size)].symbolize_keys! answers = (random_question[:answers] || '').split(',').map!{ |answer| safe_md5(answer) } if random_question && answers.present? { 'q' => random_question[:question], 'a' => answers } end end end # check textcaptcha, if incorrect, generate a new textcaptcha def validate_textcaptcha valid_answers = textcaptcha_cache.read(textcaptcha_key) || [] reset_textcaptcha if valid_answers.include?(safe_md5(textcaptcha_answer)) # answer was valid, mutate the key again self.textcaptcha_key = textcaptcha_random_key textcaptcha_cache.write(textcaptcha_key, valid_answers, textcaptcha_cache_options) true else add_textcaptcha_error(too_slow: valid_answers.empty?) textcaptcha false end end def add_textcaptcha_error(too_slow: false) if too_slow errors.add(:textcaptcha_answer, :expired, :message => 'was not submitted quickly enough, try another question instead') else errors.add(:textcaptcha_answer, :incorrect, :message => 'is incorrect, try another question instead') end end def reset_textcaptcha if textcaptcha_key textcaptcha_cache.delete(textcaptcha_key) self.textcaptcha_key = nil end end def assign_textcaptcha(q_and_a) return unless q_and_a self.textcaptcha_question = q_and_a['q'] self.textcaptcha_key = textcaptcha_random_key textcaptcha_cache.write(textcaptcha_key, q_and_a['a'], textcaptcha_cache_options) end # strip whitespace pass through mb_chars (a multibyte safe proxy for # strings) then downcase def safe_md5(answer) Digest::MD5.hexdigest(answer.to_s.strip.mb_chars.downcase) end # a random cache key, time based, random def textcaptcha_random_key safe_md5(Time.now.to_i + rand(1_000_000)) end def textcaptcha_cache_options if textcaptcha_config[:cache_expiry_minutes] { :expires_in => textcaptcha_config[:cache_expiry_minutes].to_f.minutes } else {} end end def textcaptcha_cache @@textcaptcha_cache ||= TextcaptchaCache.new end end private def build_textcaptcha_config(options) if options.is_a?(Hash) options else YAML.load(ERB.new(read_textcaptcha_config).result)[Rails.env] end rescue 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') end def read_textcaptcha_config File.read("#{Rails.root ? Rails.root : '.'}/config/textcaptcha.yml") end end end