recaptcha 4.14.0 → 5.0.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/CHANGELOG.md +13 -0
- data/README.md +332 -67
- data/lib/recaptcha.rb +67 -32
- data/lib/recaptcha/adapters/controller_methods.rb +87 -0
- data/lib/recaptcha/adapters/view_methods.rb +26 -0
- data/lib/recaptcha/configuration.rb +10 -4
- data/lib/recaptcha/helpers.rb +297 -0
- data/lib/recaptcha/railtie.rb +2 -4
- data/lib/recaptcha/version.rb +1 -1
- metadata +8 -21
- data/lib/recaptcha/client_helper.rb +0 -157
- data/lib/recaptcha/verify.rb +0 -108
    
        data/lib/recaptcha.rb
    CHANGED
    
    | @@ -1,25 +1,26 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            require ' | 
| 4 | 
            -
            require 'uri'
         | 
| 3 | 
            +
            require 'json'
         | 
| 5 4 | 
             
            require 'net/http'
         | 
| 5 | 
            +
            require 'uri'
         | 
| 6 6 |  | 
| 7 | 
            +
            require 'recaptcha/configuration'
         | 
| 8 | 
            +
            require 'recaptcha/helpers'
         | 
| 9 | 
            +
            require 'recaptcha/adapters/controller_methods'
         | 
| 10 | 
            +
            require 'recaptcha/adapters/view_methods'
         | 
| 7 11 | 
             
            if defined?(Rails)
         | 
| 8 12 | 
             
              require 'recaptcha/railtie'
         | 
| 9 | 
            -
            else
         | 
| 10 | 
            -
              require 'recaptcha/client_helper'
         | 
| 11 | 
            -
              require 'recaptcha/verify'
         | 
| 12 13 | 
             
            end
         | 
| 13 14 |  | 
| 14 15 | 
             
            module Recaptcha
         | 
| 15 | 
            -
              CONFIG = {
         | 
| 16 | 
            -
                'server_url' => 'https://www.google.com/recaptcha/api.js',
         | 
| 17 | 
            -
                'verify_url' => 'https://www.google.com/recaptcha/api/siteverify'
         | 
| 18 | 
            -
              }.freeze
         | 
| 19 | 
            -
             | 
| 20 | 
            -
              USE_SSL_BY_DEFAULT              = false
         | 
| 21 | 
            -
              HANDLE_TIMEOUTS_GRACEFULLY      = true
         | 
| 22 16 | 
             
              DEFAULT_TIMEOUT = 3
         | 
| 17 | 
            +
              RESPONSE_LIMIT = 4000
         | 
| 18 | 
            +
             | 
| 19 | 
            +
              class RecaptchaError < StandardError
         | 
| 20 | 
            +
              end
         | 
| 21 | 
            +
             | 
| 22 | 
            +
              class VerifyError < RecaptchaError
         | 
| 23 | 
            +
              end
         | 
| 23 24 |  | 
| 24 25 | 
             
              # Gives access to the current Configuration.
         | 
| 25 26 | 
             
              def self.configuration
         | 
| @@ -50,33 +51,67 @@ module Recaptcha | |
| 50 51 | 
             
                original_config.each { |key, value| configuration.send("#{key}=", value) }
         | 
| 51 52 | 
             
              end
         | 
| 52 53 |  | 
| 53 | 
            -
              def self. | 
| 54 | 
            -
                 | 
| 55 | 
            -
             | 
| 56 | 
            -
             | 
| 57 | 
            -
             | 
| 58 | 
            -
             | 
| 54 | 
            +
              def self.skip_env?(env)
         | 
| 55 | 
            +
                configuration.skip_verify_env.include?(env || configuration.default_env)
         | 
| 56 | 
            +
              end
         | 
| 57 | 
            +
             | 
| 58 | 
            +
              def self.invalid_response?(resp)
         | 
| 59 | 
            +
                resp.empty? || resp.length > RESPONSE_LIMIT
         | 
| 60 | 
            +
              end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
              def self.verify_via_api_call(response, options)
         | 
| 63 | 
            +
                secret_key = options.fetch(:secret_key) { configuration.secret_key! }
         | 
| 64 | 
            +
                verify_hash = { 'secret' => secret_key, 'response' => response }
         | 
| 65 | 
            +
                verify_hash['remoteip'] = options[:remote_ip] if options.key?(:remote_ip)
         | 
| 66 | 
            +
             | 
| 67 | 
            +
                reply = api_verification(verify_hash, timeout: options[:timeout])
         | 
| 68 | 
            +
                reply['success'].to_s == 'true' &&
         | 
| 69 | 
            +
                  hostname_valid?(reply['hostname'], options[:hostname]) &&
         | 
| 70 | 
            +
                  action_valid?(reply['action'], options[:action]) &&
         | 
| 71 | 
            +
                  score_above_threshold?(reply['score'], options[:minimum_score])
         | 
| 72 | 
            +
              end
         | 
| 73 | 
            +
             | 
| 74 | 
            +
              def self.hostname_valid?(hostname, validation)
         | 
| 75 | 
            +
                validation ||= configuration.hostname
         | 
| 76 | 
            +
             | 
| 77 | 
            +
                case validation
         | 
| 78 | 
            +
                when nil, FalseClass then true
         | 
| 79 | 
            +
                when String then validation == hostname
         | 
| 80 | 
            +
                else validation.call(hostname)
         | 
| 59 81 | 
             
                end
         | 
| 60 | 
            -
                query = URI.encode_www_form(verify_hash)
         | 
| 61 | 
            -
                uri = URI.parse(Recaptcha.configuration.verify_url + '?' + query)
         | 
| 62 | 
            -
                http_instance = http.new(uri.host, uri.port)
         | 
| 63 | 
            -
                http_instance.read_timeout = http_instance.open_timeout = options[:timeout] || DEFAULT_TIMEOUT
         | 
| 64 | 
            -
                http_instance.use_ssl = true if uri.port == 443
         | 
| 65 | 
            -
                request = Net::HTTP::Get.new(uri.request_uri)
         | 
| 66 | 
            -
                http_instance.request(request).body
         | 
| 67 82 | 
             
              end
         | 
| 68 83 |  | 
| 69 | 
            -
              def self. | 
| 70 | 
            -
                 | 
| 71 | 
            -
             | 
| 72 | 
            -
                else
         | 
| 73 | 
            -
                  default
         | 
| 84 | 
            +
              def self.action_valid?(action, expected_action)
         | 
| 85 | 
            +
                case expected_action
         | 
| 86 | 
            +
                when nil, FalseClass then true
         | 
| 87 | 
            +
                else action == expected_action
         | 
| 74 88 | 
             
                end
         | 
| 75 89 | 
             
              end
         | 
| 76 90 |  | 
| 77 | 
            -
               | 
| 91 | 
            +
              # Returns true iff score is greater or equal to (>=) minimum_score, or if no minimum_score was specified
         | 
| 92 | 
            +
              def self.score_above_threshold?(score, minimum_score)
         | 
| 93 | 
            +
                return true if minimum_score.nil?
         | 
| 94 | 
            +
                return false if score.nil?
         | 
| 95 | 
            +
             | 
| 96 | 
            +
                case minimum_score
         | 
| 97 | 
            +
                when nil, FalseClass then true
         | 
| 98 | 
            +
                else score >= minimum_score
         | 
| 99 | 
            +
                end
         | 
| 78 100 | 
             
              end
         | 
| 79 101 |  | 
| 80 | 
            -
               | 
| 102 | 
            +
              def self.api_verification(verify_hash, timeout: DEFAULT_TIMEOUT)
         | 
| 103 | 
            +
                http = if configuration.proxy
         | 
| 104 | 
            +
                  proxy_server = URI.parse(configuration.proxy)
         | 
| 105 | 
            +
                  Net::HTTP::Proxy(proxy_server.host, proxy_server.port, proxy_server.user, proxy_server.password)
         | 
| 106 | 
            +
                else
         | 
| 107 | 
            +
                  Net::HTTP
         | 
| 108 | 
            +
                end
         | 
| 109 | 
            +
                query = URI.encode_www_form(verify_hash)
         | 
| 110 | 
            +
                uri = URI.parse(configuration.verify_url + '?' + query)
         | 
| 111 | 
            +
                http_instance = http.new(uri.host, uri.port)
         | 
| 112 | 
            +
                http_instance.read_timeout = http_instance.open_timeout = timeout
         | 
| 113 | 
            +
                http_instance.use_ssl = true if uri.port == 443
         | 
| 114 | 
            +
                request = Net::HTTP::Get.new(uri.request_uri)
         | 
| 115 | 
            +
                JSON.parse(http_instance.request(request).body)
         | 
| 81 116 | 
             
              end
         | 
| 82 117 | 
             
            end
         | 
| @@ -0,0 +1,87 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Recaptcha
         | 
| 4 | 
            +
              module Adapters
         | 
| 5 | 
            +
                module ControllerMethods
         | 
| 6 | 
            +
                  private
         | 
| 7 | 
            +
             | 
| 8 | 
            +
                  # Your private API can be specified in the +options+ hash or preferably
         | 
| 9 | 
            +
                  # using the Configuration.
         | 
| 10 | 
            +
                  def verify_recaptcha(options = {})
         | 
| 11 | 
            +
                    options = {model: options} unless options.is_a? Hash
         | 
| 12 | 
            +
                    return true if Recaptcha.skip_env?(options[:env])
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                    model = options[:model]
         | 
| 15 | 
            +
                    attribute = options.fetch(:attribute, :base)
         | 
| 16 | 
            +
                    recaptcha_response = options[:response] || recaptcha_response_token(options[:action])
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                    begin
         | 
| 19 | 
            +
                      verified = if Recaptcha.invalid_response?(recaptcha_response)
         | 
| 20 | 
            +
                        false
         | 
| 21 | 
            +
                      else
         | 
| 22 | 
            +
                        unless options[:skip_remote_ip]
         | 
| 23 | 
            +
                          remoteip = (request.respond_to?(:remote_ip) && request.remote_ip) || (env && env['REMOTE_ADDR'])
         | 
| 24 | 
            +
                          options = options.merge(remote_ip: remoteip.to_s) if remoteip
         | 
| 25 | 
            +
                        end
         | 
| 26 | 
            +
             | 
| 27 | 
            +
                        Recaptcha.verify_via_api_call(recaptcha_response, options)
         | 
| 28 | 
            +
                      end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                      if verified
         | 
| 31 | 
            +
                        flash.delete(:recaptcha_error) if recaptcha_flash_supported? && !model
         | 
| 32 | 
            +
                        true
         | 
| 33 | 
            +
                      else
         | 
| 34 | 
            +
                        recaptcha_error(
         | 
| 35 | 
            +
                          model,
         | 
| 36 | 
            +
                          attribute,
         | 
| 37 | 
            +
                          options.fetch(:message) { Recaptcha::Helpers.to_error_message(:verification_failed) }
         | 
| 38 | 
            +
                        )
         | 
| 39 | 
            +
                        false
         | 
| 40 | 
            +
                      end
         | 
| 41 | 
            +
                    rescue Timeout::Error
         | 
| 42 | 
            +
                      if Recaptcha.configuration.handle_timeouts_gracefully
         | 
| 43 | 
            +
                        recaptcha_error(
         | 
| 44 | 
            +
                          model,
         | 
| 45 | 
            +
                          attribute,
         | 
| 46 | 
            +
                          options.fetch(:message) { Recaptcha::Helpers.to_error_message(:recaptcha_unreachable) }
         | 
| 47 | 
            +
                        )
         | 
| 48 | 
            +
                        false
         | 
| 49 | 
            +
                      else
         | 
| 50 | 
            +
                        raise RecaptchaError, 'Recaptcha unreachable.'
         | 
| 51 | 
            +
                      end
         | 
| 52 | 
            +
                    rescue StandardError => e
         | 
| 53 | 
            +
                      raise RecaptchaError, e.message, e.backtrace
         | 
| 54 | 
            +
                    end
         | 
| 55 | 
            +
                  end
         | 
| 56 | 
            +
             | 
| 57 | 
            +
                  def verify_recaptcha!(options = {})
         | 
| 58 | 
            +
                    verify_recaptcha(options) || raise(VerifyError)
         | 
| 59 | 
            +
                  end
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                  def recaptcha_error(model, attribute, message)
         | 
| 62 | 
            +
                    if model
         | 
| 63 | 
            +
                      model.errors.add(attribute, message)
         | 
| 64 | 
            +
                    elsif recaptcha_flash_supported?
         | 
| 65 | 
            +
                      flash[:recaptcha_error] = message
         | 
| 66 | 
            +
                    end
         | 
| 67 | 
            +
                  end
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                  def recaptcha_flash_supported?
         | 
| 70 | 
            +
                    request.respond_to?(:format) && request.format == :html && respond_to?(:flash)
         | 
| 71 | 
            +
                  end
         | 
| 72 | 
            +
             | 
| 73 | 
            +
                  # Extracts response token from params. params['g-recaptcha-response'] should either be a
         | 
| 74 | 
            +
                  # string or a hash with the action name(s) as keys. If it is a hash, then `action` is used as
         | 
| 75 | 
            +
                  # the key.
         | 
| 76 | 
            +
                  # @return [String] A response token if one was passed in the params; otherwise, `''`
         | 
| 77 | 
            +
                  def recaptcha_response_token(action = nil)
         | 
| 78 | 
            +
                    response_param = params['g-recaptcha-response']
         | 
| 79 | 
            +
                    if response_param&.respond_to?(:to_h) # Includes ActionController::Parameters
         | 
| 80 | 
            +
                      response_param[action].to_s
         | 
| 81 | 
            +
                    else
         | 
| 82 | 
            +
                      response_param.to_s
         | 
| 83 | 
            +
                    end
         | 
| 84 | 
            +
                  end
         | 
| 85 | 
            +
                end
         | 
| 86 | 
            +
              end
         | 
| 87 | 
            +
            end
         | 
| @@ -0,0 +1,26 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Recaptcha
         | 
| 4 | 
            +
              module Adapters
         | 
| 5 | 
            +
                module ViewMethods
         | 
| 6 | 
            +
                  # Renders a [reCAPTCHA v3](https://developers.google.com/recaptcha/docs/v3) script and (by
         | 
| 7 | 
            +
                  # default) a hidden input to submit the response token. You can also call the functions
         | 
| 8 | 
            +
                  # directly if you prefer. You can use
         | 
| 9 | 
            +
                  # `Recaptcha::Helpers.recaptcha_v3_execute_function_name(action)` to get the name of the
         | 
| 10 | 
            +
                  # function to call.
         | 
| 11 | 
            +
                  def recaptcha_v3(options = {})
         | 
| 12 | 
            +
                    ::Recaptcha::Helpers.recaptcha_v3(options)
         | 
| 13 | 
            +
                  end
         | 
| 14 | 
            +
             | 
| 15 | 
            +
                  # Renders a reCAPTCHA [v2 Checkbox](https://developers.google.com/recaptcha/docs/display) widget
         | 
| 16 | 
            +
                  def recaptcha_tags(options = {})
         | 
| 17 | 
            +
                    ::Recaptcha::Helpers.recaptcha_tags(options)
         | 
| 18 | 
            +
                  end
         | 
| 19 | 
            +
             | 
| 20 | 
            +
                  # Renders a reCAPTCHA v2 [Invisible reCAPTCHA](https://developers.google.com/recaptcha/docs/invisible)
         | 
| 21 | 
            +
                  def invisible_recaptcha_tags(options = {})
         | 
| 22 | 
            +
                    ::Recaptcha::Helpers.invisible_recaptcha_tags(options)
         | 
| 23 | 
            +
                  end
         | 
| 24 | 
            +
                end
         | 
| 25 | 
            +
              end
         | 
| 26 | 
            +
            end
         | 
| @@ -30,12 +30,18 @@ module Recaptcha | |
| 30 30 | 
             
              #   end
         | 
| 31 31 | 
             
              #
         | 
| 32 32 | 
             
              class Configuration
         | 
| 33 | 
            -
                 | 
| 33 | 
            +
                DEFAULTS = {
         | 
| 34 | 
            +
                  'server_url' => 'https://www.recaptcha.net/recaptcha/api.js',
         | 
| 35 | 
            +
                  'verify_url' => 'https://www.recaptcha.net/recaptcha/api/siteverify'
         | 
| 36 | 
            +
                }.freeze
         | 
| 37 | 
            +
             | 
| 38 | 
            +
                attr_accessor :default_env, :skip_verify_env, :secret_key, :site_key, :proxy, :handle_timeouts_gracefully, :hostname
         | 
| 34 39 | 
             
                attr_writer :api_server_url, :verify_url
         | 
| 35 40 |  | 
| 36 41 | 
             
                def initialize #:nodoc:
         | 
| 42 | 
            +
                  @default_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || (Rails.env if defined? Rails.env)
         | 
| 37 43 | 
             
                  @skip_verify_env = %w[test cucumber]
         | 
| 38 | 
            -
                  @handle_timeouts_gracefully =  | 
| 44 | 
            +
                  @handle_timeouts_gracefully = true
         | 
| 39 45 |  | 
| 40 46 | 
             
                  @secret_key = ENV['RECAPTCHA_SECRET_KEY']
         | 
| 41 47 | 
             
                  @site_key = ENV['RECAPTCHA_SITE_KEY']
         | 
| @@ -52,11 +58,11 @@ module Recaptcha | |
| 52 58 | 
             
                end
         | 
| 53 59 |  | 
| 54 60 | 
             
                def api_server_url
         | 
| 55 | 
            -
                  @api_server_url ||  | 
| 61 | 
            +
                  @api_server_url || DEFAULTS.fetch('server_url')
         | 
| 56 62 | 
             
                end
         | 
| 57 63 |  | 
| 58 64 | 
             
                def verify_url
         | 
| 59 | 
            -
                  @verify_url ||  | 
| 65 | 
            +
                  @verify_url || DEFAULTS.fetch('verify_url')
         | 
| 60 66 | 
             
                end
         | 
| 61 67 | 
             
              end
         | 
| 62 68 | 
             
            end
         | 
| @@ -0,0 +1,297 @@ | |
| 1 | 
            +
            # frozen_string_literal: true
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            module Recaptcha
         | 
| 4 | 
            +
              module Helpers
         | 
| 5 | 
            +
                DEFAULT_ERRORS = {
         | 
| 6 | 
            +
                  recaptcha_unreachable: 'Oops, we failed to validate your reCAPTCHA response. Please try again.',
         | 
| 7 | 
            +
                  verification_failed: 'reCAPTCHA verification failed, please try again.'
         | 
| 8 | 
            +
                }.freeze
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                def self.recaptcha_v3(options = {})
         | 
| 11 | 
            +
                  site_key = options[:site_key] ||= Recaptcha.configuration.site_key!
         | 
| 12 | 
            +
                  action = options.delete(:action) || raise(Recaptcha::RecaptchaError, 'action is required')
         | 
| 13 | 
            +
                  id   = options.delete(:id)   || "g-recaptcha-response-" + dasherize_action(action)
         | 
| 14 | 
            +
                  name = options.delete(:name) || "g-recaptcha-response[#{action}]"
         | 
| 15 | 
            +
                  options[:render] = site_key
         | 
| 16 | 
            +
                  options[:script_async] ||= false
         | 
| 17 | 
            +
                  options[:script_defer] ||= false
         | 
| 18 | 
            +
                  element = options.delete(:element)
         | 
| 19 | 
            +
                  element = element == false ? false : :input
         | 
| 20 | 
            +
                  if element == :input
         | 
| 21 | 
            +
                    callback = options.delete(:callback) || recaptcha_v3_default_callback_name(action)
         | 
| 22 | 
            +
                  end
         | 
| 23 | 
            +
                  options[:class] = "g-recaptcha-response #{options[:class]}"
         | 
| 24 | 
            +
             | 
| 25 | 
            +
                  html, tag_attributes = components(options)
         | 
| 26 | 
            +
                  if recaptcha_v3_inline_script?(options)
         | 
| 27 | 
            +
                    html << recaptcha_v3_inline_script(site_key, action, callback, id, options)
         | 
| 28 | 
            +
                  end
         | 
| 29 | 
            +
                  case element
         | 
| 30 | 
            +
                  when :input
         | 
| 31 | 
            +
                    html << %(<input type="hidden" name="#{name}" id="#{id}" #{tag_attributes}/>\n)
         | 
| 32 | 
            +
                  when false
         | 
| 33 | 
            +
                    # No tag
         | 
| 34 | 
            +
                    nil
         | 
| 35 | 
            +
                  else
         | 
| 36 | 
            +
                    raise(RecaptchaError, "ReCAPTCHA element `#{options[:element]}` is not valid.")
         | 
| 37 | 
            +
                  end
         | 
| 38 | 
            +
                  html.respond_to?(:html_safe) ? html.html_safe : html
         | 
| 39 | 
            +
                end
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                def self.recaptcha_tags(options)
         | 
| 42 | 
            +
                  if options.key?(:stoken)
         | 
| 43 | 
            +
                    raise(RecaptchaError, "Secure Token is deprecated. Please remove 'stoken' from your calls to recaptcha_tags.")
         | 
| 44 | 
            +
                  end
         | 
| 45 | 
            +
                  if options.key?(:ssl)
         | 
| 46 | 
            +
                    raise(RecaptchaError, "SSL is now always true. Please remove 'ssl' from your calls to recaptcha_tags.")
         | 
| 47 | 
            +
                  end
         | 
| 48 | 
            +
             | 
| 49 | 
            +
                  noscript = options.delete(:noscript)
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  html, tag_attributes, fallback_uri = components(options.dup)
         | 
| 52 | 
            +
                  html << %(<div #{tag_attributes}></div>\n)
         | 
| 53 | 
            +
             | 
| 54 | 
            +
                  if noscript != false
         | 
| 55 | 
            +
                    html << <<-HTML
         | 
| 56 | 
            +
                      <noscript>
         | 
| 57 | 
            +
                        <div>
         | 
| 58 | 
            +
                          <div style="width: 302px; height: 422px; position: relative;">
         | 
| 59 | 
            +
                            <div style="width: 302px; height: 422px; position: absolute;">
         | 
| 60 | 
            +
                              <iframe
         | 
| 61 | 
            +
                                src="#{fallback_uri}"
         | 
| 62 | 
            +
                                name="ReCAPTCHA"
         | 
| 63 | 
            +
                                style="width: 302px; height: 422px; border-style: none; border: 0; overflow: hidden;">
         | 
| 64 | 
            +
                              </iframe>
         | 
| 65 | 
            +
                            </div>
         | 
| 66 | 
            +
                          </div>
         | 
| 67 | 
            +
                          <div style="width: 300px; height: 60px; border-style: none;
         | 
| 68 | 
            +
                            bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
         | 
| 69 | 
            +
                            background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
         | 
| 70 | 
            +
                            <textarea id="g-recaptcha-response" name="g-recaptcha-response"
         | 
| 71 | 
            +
                              class="g-recaptcha-response"
         | 
| 72 | 
            +
                              style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
         | 
| 73 | 
            +
                              margin: 10px 25px; padding: 0px; resize: none;">
         | 
| 74 | 
            +
                            </textarea>
         | 
| 75 | 
            +
                          </div>
         | 
| 76 | 
            +
                        </div>
         | 
| 77 | 
            +
                      </noscript>
         | 
| 78 | 
            +
                    HTML
         | 
| 79 | 
            +
                  end
         | 
| 80 | 
            +
             | 
| 81 | 
            +
                  html.respond_to?(:html_safe) ? html.html_safe : html
         | 
| 82 | 
            +
                end
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                def self.invisible_recaptcha_tags(custom)
         | 
| 85 | 
            +
                  options = {callback: 'invisibleRecaptchaSubmit', ui: :button}.merge(custom)
         | 
| 86 | 
            +
                  text = options.delete(:text)
         | 
| 87 | 
            +
                  html, tag_attributes = components(options.dup)
         | 
| 88 | 
            +
                  html << default_callback(options) if default_callback_required?(options)
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                  case options[:ui]
         | 
| 91 | 
            +
                  when :button
         | 
| 92 | 
            +
                    html << %(<button type="submit" #{tag_attributes}>#{text}</button>\n)
         | 
| 93 | 
            +
                  when :invisible
         | 
| 94 | 
            +
                    html << %(<div data-size="invisible" #{tag_attributes}></div>\n)
         | 
| 95 | 
            +
                  when :input
         | 
| 96 | 
            +
                    html << %(<input type="submit" #{tag_attributes} value="#{text}"/>\n)
         | 
| 97 | 
            +
                  else
         | 
| 98 | 
            +
                    raise(RecaptchaError, "ReCAPTCHA ui `#{options[:ui]}` is not valid.")
         | 
| 99 | 
            +
                  end
         | 
| 100 | 
            +
                  html.respond_to?(:html_safe) ? html.html_safe : html
         | 
| 101 | 
            +
                end
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                def self.to_error_message(key)
         | 
| 104 | 
            +
                  default = DEFAULT_ERRORS.fetch(key) { raise ArgumentError "Unknown reCAPTCHA error - #{key}" }
         | 
| 105 | 
            +
                  to_message("recaptcha.errors.#{key}", default)
         | 
| 106 | 
            +
                end
         | 
| 107 | 
            +
             | 
| 108 | 
            +
                if defined?(I18n)
         | 
| 109 | 
            +
                  def self.to_message(key, default)
         | 
| 110 | 
            +
                    I18n.translate(key, default: default)
         | 
| 111 | 
            +
                  end
         | 
| 112 | 
            +
                else
         | 
| 113 | 
            +
                  def self.to_message(_key, default)
         | 
| 114 | 
            +
                    default
         | 
| 115 | 
            +
                  end
         | 
| 116 | 
            +
                end
         | 
| 117 | 
            +
             | 
| 118 | 
            +
                private_class_method def self.components(options)
         | 
| 119 | 
            +
                  html = +''
         | 
| 120 | 
            +
                  attributes = {}
         | 
| 121 | 
            +
                  fallback_uri = +''
         | 
| 122 | 
            +
             | 
| 123 | 
            +
                  options = options.dup
         | 
| 124 | 
            +
                  env = options.delete(:env)
         | 
| 125 | 
            +
                  class_attribute = options.delete(:class)
         | 
| 126 | 
            +
                  site_key = options.delete(:site_key)
         | 
| 127 | 
            +
                  hl = options.delete(:hl)
         | 
| 128 | 
            +
                  onload = options.delete(:onload)
         | 
| 129 | 
            +
                  render = options.delete(:render)
         | 
| 130 | 
            +
                  script_async = options.delete(:script_async)
         | 
| 131 | 
            +
                  script_defer = options.delete(:script_defer)
         | 
| 132 | 
            +
                  nonce = options.delete(:nonce)
         | 
| 133 | 
            +
                  skip_script = (options.delete(:script) == false) || (options.delete(:external_script) == false)
         | 
| 134 | 
            +
                  ui = options.delete(:ui)
         | 
| 135 | 
            +
             | 
| 136 | 
            +
                  data_attribute_keys = [:badge, :theme, :type, :callback, :expired_callback, :error_callback, :size]
         | 
| 137 | 
            +
                  data_attribute_keys << :tabindex unless ui == :button
         | 
| 138 | 
            +
                  data_attributes = {}
         | 
| 139 | 
            +
                  data_attribute_keys.each do |data_attribute|
         | 
| 140 | 
            +
                    value = options.delete(data_attribute)
         | 
| 141 | 
            +
                    data_attributes["data-#{data_attribute.to_s.tr('_', '-')}"] = value if value
         | 
| 142 | 
            +
                  end
         | 
| 143 | 
            +
             | 
| 144 | 
            +
                  unless Recaptcha.skip_env?(env)
         | 
| 145 | 
            +
                    site_key ||= Recaptcha.configuration.site_key!
         | 
| 146 | 
            +
                    script_url = Recaptcha.configuration.api_server_url
         | 
| 147 | 
            +
                    query_params = hash_to_query(
         | 
| 148 | 
            +
                      hl: hl,
         | 
| 149 | 
            +
                      onload: onload,
         | 
| 150 | 
            +
                      render: render
         | 
| 151 | 
            +
                    )
         | 
| 152 | 
            +
                    script_url += "?#{query_params}" unless query_params.empty?
         | 
| 153 | 
            +
                    async_attr = "async" if script_async != false
         | 
| 154 | 
            +
                    defer_attr = "defer" if script_defer != false
         | 
| 155 | 
            +
                    nonce_attr = " nonce='#{nonce}'" if nonce
         | 
| 156 | 
            +
                    html << %(<script src="#{script_url}" #{async_attr} #{defer_attr} #{nonce_attr}></script>\n) unless skip_script
         | 
| 157 | 
            +
                    fallback_uri = %(#{script_url.chomp(".js")}/fallback?k=#{site_key})
         | 
| 158 | 
            +
                    attributes["data-sitekey"] = site_key
         | 
| 159 | 
            +
                    attributes.merge! data_attributes
         | 
| 160 | 
            +
                  end
         | 
| 161 | 
            +
             | 
| 162 | 
            +
                  # The remaining options will be added as attributes on the tag.
         | 
| 163 | 
            +
                  attributes["class"] = "g-recaptcha #{class_attribute}"
         | 
| 164 | 
            +
                  tag_attributes = attributes.merge(options).map { |k, v| %(#{k}="#{v}") }.join(" ")
         | 
| 165 | 
            +
             | 
| 166 | 
            +
                  [html, tag_attributes, fallback_uri]
         | 
| 167 | 
            +
                end
         | 
| 168 | 
            +
             | 
| 169 | 
            +
                # v3
         | 
| 170 | 
            +
             | 
| 171 | 
            +
                # Renders a script that calls `grecaptcha.execute` for the given `site_key` and `action` and
         | 
| 172 | 
            +
                # calls the `callback` with the resulting response token.
         | 
| 173 | 
            +
                private_class_method def self.recaptcha_v3_inline_script(site_key, action, callback, id, options = {})
         | 
| 174 | 
            +
                  nonce = options[:nonce]
         | 
| 175 | 
            +
                  nonce_attr = " nonce='#{nonce}'" if nonce
         | 
| 176 | 
            +
             | 
| 177 | 
            +
                  <<-HTML
         | 
| 178 | 
            +
                    <script#{nonce_attr}>
         | 
| 179 | 
            +
                      // Define function so that we can call it again later if we need to reset it
         | 
| 180 | 
            +
                      function #{recaptcha_v3_execute_function_name(action)}() {
         | 
| 181 | 
            +
                        grecaptcha.ready(function() {
         | 
| 182 | 
            +
                          grecaptcha.execute('#{site_key}', {action: '#{action}'}).then(function(token) {
         | 
| 183 | 
            +
                            //console.log('#{id}', token)
         | 
| 184 | 
            +
                            #{callback}('#{id}', token)
         | 
| 185 | 
            +
                          });
         | 
| 186 | 
            +
                        });
         | 
| 187 | 
            +
                      };
         | 
| 188 | 
            +
                      // Invoke immediately
         | 
| 189 | 
            +
                      #{recaptcha_v3_execute_function_name(action)}()
         | 
| 190 | 
            +
             | 
| 191 | 
            +
                      // Async variant so you can await this function from another async function (no need for
         | 
| 192 | 
            +
                      // an explicit callback function then!)
         | 
| 193 | 
            +
                      async function #{recaptcha_v3_async_execute_function_name(action)}() {
         | 
| 194 | 
            +
                        return new Promise((resolve, reject) => {
         | 
| 195 | 
            +
                          grecaptcha.ready(async function() {
         | 
| 196 | 
            +
                            resolve(await grecaptcha.execute('#{site_key}', {action: '#{action}'}))
         | 
| 197 | 
            +
                          });
         | 
| 198 | 
            +
                        })
         | 
| 199 | 
            +
                      };
         | 
| 200 | 
            +
             | 
| 201 | 
            +
                      #{recaptcha_v3_define_default_callback(callback) if recaptcha_v3_define_default_callback?(callback, action, options)}
         | 
| 202 | 
            +
                    </script>
         | 
| 203 | 
            +
                  HTML
         | 
| 204 | 
            +
                end
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                private_class_method def self.recaptcha_v3_inline_script?(options)
         | 
| 207 | 
            +
                  !Recaptcha.skip_env?(options[:env]) &&
         | 
| 208 | 
            +
                  options[:script] != false &&
         | 
| 209 | 
            +
                  options[:inline_script] != false
         | 
| 210 | 
            +
                end
         | 
| 211 | 
            +
             | 
| 212 | 
            +
                private_class_method def self.recaptcha_v3_define_default_callback(callback)
         | 
| 213 | 
            +
                  <<-HTML
         | 
| 214 | 
            +
                      var #{callback} = function(id, token) {
         | 
| 215 | 
            +
                        var element = document.getElementById(id);
         | 
| 216 | 
            +
                        element.value = token;
         | 
| 217 | 
            +
                      }
         | 
| 218 | 
            +
                    </script>
         | 
| 219 | 
            +
                  HTML
         | 
| 220 | 
            +
                end
         | 
| 221 | 
            +
             | 
| 222 | 
            +
                # Returns true if we should be adding the default callback.
         | 
| 223 | 
            +
                # That is, if the given callback name is the default callback name (for the given action) and we
         | 
| 224 | 
            +
                # are not skipping inline scripts for any reason.
         | 
| 225 | 
            +
                private_class_method def self.recaptcha_v3_define_default_callback?(callback, action, options)
         | 
| 226 | 
            +
                  callback == recaptcha_v3_default_callback_name(action) &&
         | 
| 227 | 
            +
                  recaptcha_v3_inline_script?(options)
         | 
| 228 | 
            +
                end
         | 
| 229 | 
            +
             | 
| 230 | 
            +
                # Returns the name of the JavaScript function that actually executes the reCAPTCHA code (calls
         | 
| 231 | 
            +
                # grecaptcha.execute). You can call it again later to reset it.
         | 
| 232 | 
            +
                def self.recaptcha_v3_execute_function_name(action)
         | 
| 233 | 
            +
                  "executeRecaptchaFor#{sanitize_action_for_js(action)}"
         | 
| 234 | 
            +
                end
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                # Returns the name of an async JavaScript function that executes the reCAPTCHA code.
         | 
| 237 | 
            +
                def self.recaptcha_v3_async_execute_function_name(action)
         | 
| 238 | 
            +
                  "#{recaptcha_v3_execute_function_name(action)}Async"
         | 
| 239 | 
            +
                end
         | 
| 240 | 
            +
             | 
| 241 | 
            +
                def self.recaptcha_v3_default_callback_name(action)
         | 
| 242 | 
            +
                  "setInputWithRecaptchaResponseTokenFor#{sanitize_action_for_js(action)}"
         | 
| 243 | 
            +
                end
         | 
| 244 | 
            +
             | 
| 245 | 
            +
                # v2
         | 
| 246 | 
            +
             | 
| 247 | 
            +
                private_class_method def self.default_callback(options = {})
         | 
| 248 | 
            +
                  nonce = options[:nonce]
         | 
| 249 | 
            +
                  nonce_attr = " nonce='#{nonce}'" if nonce
         | 
| 250 | 
            +
             | 
| 251 | 
            +
                  <<-HTML
         | 
| 252 | 
            +
                    <script#{nonce_attr}>
         | 
| 253 | 
            +
                      var invisibleRecaptchaSubmit = function () {
         | 
| 254 | 
            +
                        var closestForm = function (ele) {
         | 
| 255 | 
            +
                          var curEle = ele.parentNode;
         | 
| 256 | 
            +
                          while (curEle.nodeName !== 'FORM' && curEle.nodeName !== 'BODY'){
         | 
| 257 | 
            +
                            curEle = curEle.parentNode;
         | 
| 258 | 
            +
                          }
         | 
| 259 | 
            +
                          return curEle.nodeName === 'FORM' ? curEle : null
         | 
| 260 | 
            +
                        };
         | 
| 261 | 
            +
             | 
| 262 | 
            +
                        var eles = document.getElementsByClassName('g-recaptcha');
         | 
| 263 | 
            +
                        if (eles.length > 0) {
         | 
| 264 | 
            +
                          var form = closestForm(eles[0]);
         | 
| 265 | 
            +
                          if (form) {
         | 
| 266 | 
            +
                            form.submit();
         | 
| 267 | 
            +
                          }
         | 
| 268 | 
            +
                        }
         | 
| 269 | 
            +
                      };
         | 
| 270 | 
            +
                    </script>
         | 
| 271 | 
            +
                  HTML
         | 
| 272 | 
            +
                end
         | 
| 273 | 
            +
             | 
| 274 | 
            +
                private_class_method def self.default_callback_required?(options)
         | 
| 275 | 
            +
                  options[:callback] == 'invisibleRecaptchaSubmit' &&
         | 
| 276 | 
            +
                  !Recaptcha.skip_env?(options[:env]) &&
         | 
| 277 | 
            +
                  options[:script] != false &&
         | 
| 278 | 
            +
                  options[:inline_script] != false
         | 
| 279 | 
            +
                end
         | 
| 280 | 
            +
             | 
| 281 | 
            +
                # Returns a camelized string that is safe for use in a JavaScript variable/function name.
         | 
| 282 | 
            +
                # sanitize_action_for_js('my/action') => 'MyAction'
         | 
| 283 | 
            +
                private_class_method def self.sanitize_action_for_js(action)
         | 
| 284 | 
            +
                  action.to_s.gsub(/\W/, '_').camelize
         | 
| 285 | 
            +
                end
         | 
| 286 | 
            +
             | 
| 287 | 
            +
                # Returns a dasherized string that is safe for use as an HTML ID
         | 
| 288 | 
            +
                # dasherize_action('my/action') => 'my-action'
         | 
| 289 | 
            +
                private_class_method def self.dasherize_action(action)
         | 
| 290 | 
            +
                  action.to_s.gsub(/\W/, '-').dasherize
         | 
| 291 | 
            +
                end
         | 
| 292 | 
            +
             | 
| 293 | 
            +
                private_class_method def self.hash_to_query(hash)
         | 
| 294 | 
            +
                  hash.delete_if { |_, val| val.nil? || val.empty? }.to_a.map { |pair| pair.join('=') }.join('&')
         | 
| 295 | 
            +
                end
         | 
| 296 | 
            +
              end
         | 
| 297 | 
            +
            end
         |