recaptcha 4.14.0 → 5.14.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.
@@ -0,0 +1,96 @@
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
+ success, @_recaptcha_reply =
28
+ Recaptcha.verify_via_api_call(recaptcha_response, options.merge(with_reply: true))
29
+ success
30
+ end
31
+
32
+ if verified
33
+ flash.delete(:recaptcha_error) if recaptcha_flash_supported? && !model
34
+ true
35
+ else
36
+ recaptcha_error(
37
+ model,
38
+ attribute,
39
+ options.fetch(:message) { Recaptcha::Helpers.to_error_message(:verification_failed) }
40
+ )
41
+ false
42
+ end
43
+ rescue Timeout::Error
44
+ if Recaptcha.configuration.handle_timeouts_gracefully
45
+ recaptcha_error(
46
+ model,
47
+ attribute,
48
+ options.fetch(:message) { Recaptcha::Helpers.to_error_message(:recaptcha_unreachable) }
49
+ )
50
+ false
51
+ else
52
+ raise RecaptchaError, 'Recaptcha unreachable.'
53
+ end
54
+ rescue StandardError => e
55
+ raise RecaptchaError, e.message, e.backtrace
56
+ end
57
+ end
58
+
59
+ def verify_recaptcha!(options = {})
60
+ verify_recaptcha(options) || raise(VerifyError)
61
+ end
62
+
63
+ def recaptcha_reply
64
+ @_recaptcha_reply if defined?(@_recaptcha_reply)
65
+ end
66
+
67
+ def recaptcha_error(model, attribute, message)
68
+ if model
69
+ model.errors.add(attribute, message)
70
+ elsif recaptcha_flash_supported?
71
+ flash[:recaptcha_error] = message
72
+ end
73
+ end
74
+
75
+ def recaptcha_flash_supported?
76
+ request.respond_to?(:format) && request.format == :html && respond_to?(:flash)
77
+ end
78
+
79
+ # Extracts response token from params. params['g-recaptcha-response-data'] for recaptcha_v3 or
80
+ # params['g-recaptcha-response'] for recaptcha_tags and invisible_recaptcha_tags and should
81
+ # either be a string or a hash with the action name(s) as keys. If it is a hash, then `action`
82
+ # is used as the key.
83
+ # @return [String] A response token if one was passed in the params; otherwise, `''`
84
+ def recaptcha_response_token(action = nil)
85
+ response_param = params['g-recaptcha-response-data'] || params['g-recaptcha-response']
86
+ response_param = response_param[action] if action && response_param.respond_to?(:key?)
87
+
88
+ if response_param.is_a?(String)
89
+ response_param
90
+ else
91
+ ''
92
+ end
93
+ end
94
+ end
95
+ end
96
+ 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,17 +30,33 @@ module Recaptcha
30
30
  # end
31
31
  #
32
32
  class Configuration
33
- attr_accessor :skip_verify_env, :secret_key, :site_key, :proxy, :handle_timeouts_gracefully, :hostname
33
+ DEFAULTS = {
34
+ 'free_server_url' => 'https://www.recaptcha.net/recaptcha/api.js',
35
+ 'enterprise_server_url' => 'https://www.recaptcha.net/recaptcha/enterprise.js',
36
+ 'free_verify_url' => 'https://www.recaptcha.net/recaptcha/api/siteverify',
37
+ 'enterprise_verify_url' => 'https://recaptchaenterprise.googleapis.com/v1/projects'
38
+ }.freeze
39
+
40
+ attr_accessor :default_env, :skip_verify_env, :proxy, :secret_key, :site_key, :handle_timeouts_gracefully,
41
+ :hostname, :enterprise, :enterprise_api_key, :enterprise_project_id, :response_limit
34
42
  attr_writer :api_server_url, :verify_url
35
43
 
36
- def initialize #:nodoc:
44
+ def initialize # :nodoc:
45
+ @default_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || (Rails.env if defined? Rails.env)
37
46
  @skip_verify_env = %w[test cucumber]
38
- @handle_timeouts_gracefully = HANDLE_TIMEOUTS_GRACEFULLY
47
+ @handle_timeouts_gracefully = true
39
48
 
40
49
  @secret_key = ENV['RECAPTCHA_SECRET_KEY']
41
50
  @site_key = ENV['RECAPTCHA_SITE_KEY']
51
+
52
+ @enterprise = ENV['RECAPTCHA_ENTERPRISE'] == 'true'
53
+ @enterprise_api_key = ENV['RECAPTCHA_ENTERPRISE_API_KEY']
54
+ @enterprise_project_id = ENV['RECAPTCHA_ENTERPRISE_PROJECT_ID']
55
+
42
56
  @verify_url = nil
43
57
  @api_server_url = nil
58
+
59
+ @response_limit = 4000
44
60
  end
45
61
 
46
62
  def secret_key!
@@ -51,12 +67,20 @@ module Recaptcha
51
67
  site_key || raise(RecaptchaError, "No site key specified.")
52
68
  end
53
69
 
70
+ def enterprise_api_key!
71
+ enterprise_api_key || raise(RecaptchaError, "No Enterprise API key specified.")
72
+ end
73
+
74
+ def enterprise_project_id!
75
+ enterprise_project_id || raise(RecaptchaError, "No Enterprise project ID specified.")
76
+ end
77
+
54
78
  def api_server_url
55
- @api_server_url || CONFIG.fetch('server_url')
79
+ @api_server_url || (enterprise ? DEFAULTS.fetch('enterprise_server_url') : DEFAULTS.fetch('free_server_url'))
56
80
  end
57
81
 
58
82
  def verify_url
59
- @verify_url || CONFIG.fetch('verify_url')
83
+ @verify_url || (enterprise ? DEFAULTS.fetch('enterprise_verify_url') : DEFAULTS.fetch('free_verify_url'))
60
84
  end
61
85
  end
62
86
  end
@@ -0,0 +1,338 @@
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-data-#{dasherize_action(action)}"
14
+ name = options.delete(:name) || "g-recaptcha-response-data[#{action}]"
15
+ turbolinks = options.delete(:turbolinks)
16
+ options[:render] = site_key
17
+ options[:script_async] ||= false
18
+ options[:script_defer] ||= false
19
+ options[:ignore_no_element] = options.key?(:ignore_no_element) ? options[:ignore_no_element] : true
20
+ element = options.delete(:element)
21
+ element = element == false ? false : :input
22
+ if element == :input
23
+ callback = options.delete(:callback) || recaptcha_v3_default_callback_name(action)
24
+ end
25
+ options[:class] = "g-recaptcha-response #{options[:class]}"
26
+
27
+ if turbolinks
28
+ options[:onload] = recaptcha_v3_execute_function_name(action)
29
+ end
30
+ html, tag_attributes = components(options)
31
+ if turbolinks
32
+ html << recaptcha_v3_onload_script(site_key, action, callback, id, options)
33
+ elsif recaptcha_v3_inline_script?(options)
34
+ html << recaptcha_v3_inline_script(site_key, action, callback, id, options)
35
+ end
36
+ case element
37
+ when :input
38
+ html << %(<input type="hidden" name="#{name}" id="#{id}" #{tag_attributes}/>\n)
39
+ when false
40
+ # No tag
41
+ nil
42
+ else
43
+ raise(RecaptchaError, "ReCAPTCHA element `#{options[:element]}` is not valid.")
44
+ end
45
+ html.respond_to?(:html_safe) ? html.html_safe : html
46
+ end
47
+
48
+ def self.recaptcha_tags(options)
49
+ if options.key?(:stoken)
50
+ raise(RecaptchaError, "Secure Token is deprecated. Please remove 'stoken' from your calls to recaptcha_tags.")
51
+ end
52
+ if options.key?(:ssl)
53
+ raise(RecaptchaError, "SSL is now always true. Please remove 'ssl' from your calls to recaptcha_tags.")
54
+ end
55
+
56
+ noscript = options.delete(:noscript)
57
+
58
+ html, tag_attributes, fallback_uri = components(options.dup)
59
+ html << %(<div #{tag_attributes}></div>\n)
60
+
61
+ if noscript != false
62
+ html << <<-HTML
63
+ <noscript>
64
+ <div>
65
+ <div style="width: 302px; height: 422px; position: relative;">
66
+ <div style="width: 302px; height: 422px; position: absolute;">
67
+ <iframe
68
+ src="#{fallback_uri}"
69
+ name="ReCAPTCHA"
70
+ style="width: 302px; height: 422px; border-style: none; border: 0; overflow: hidden;">
71
+ </iframe>
72
+ </div>
73
+ </div>
74
+ <div style="width: 300px; height: 60px; border-style: none;
75
+ bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;
76
+ background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;">
77
+ <textarea id="g-recaptcha-response" name="g-recaptcha-response"
78
+ class="g-recaptcha-response"
79
+ style="width: 250px; height: 40px; border: 1px solid #c1c1c1;
80
+ margin: 10px 25px; padding: 0px; resize: none;">
81
+ </textarea>
82
+ </div>
83
+ </div>
84
+ </noscript>
85
+ HTML
86
+ end
87
+
88
+ html.respond_to?(:html_safe) ? html.html_safe : html
89
+ end
90
+
91
+ def self.invisible_recaptcha_tags(custom)
92
+ options = {callback: 'invisibleRecaptchaSubmit', ui: :button}.merge(custom)
93
+ text = options.delete(:text)
94
+ html, tag_attributes = components(options.dup)
95
+ html << default_callback(options) if default_callback_required?(options)
96
+
97
+ case options[:ui]
98
+ when :button
99
+ html << %(<button type="submit" #{tag_attributes}>#{text}</button>\n)
100
+ when :invisible
101
+ html << %(<div data-size="invisible" #{tag_attributes}></div>\n)
102
+ when :input
103
+ html << %(<input type="submit" #{tag_attributes} value="#{text}"/>\n)
104
+ else
105
+ raise(RecaptchaError, "ReCAPTCHA ui `#{options[:ui]}` is not valid.")
106
+ end
107
+ html.respond_to?(:html_safe) ? html.html_safe : html
108
+ end
109
+
110
+ def self.to_error_message(key)
111
+ default = DEFAULT_ERRORS.fetch(key) { raise ArgumentError "Unknown reCAPTCHA error - #{key}" }
112
+ to_message("recaptcha.errors.#{key}", default)
113
+ end
114
+
115
+ if defined?(I18n)
116
+ def self.to_message(key, default)
117
+ I18n.translate(key, default: default)
118
+ end
119
+ else
120
+ def self.to_message(_key, default)
121
+ default
122
+ end
123
+ end
124
+
125
+ private_class_method def self.components(options)
126
+ html = +''
127
+ attributes = {}
128
+ fallback_uri = +''
129
+
130
+ options = options.dup
131
+ env = options.delete(:env)
132
+ class_attribute = options.delete(:class)
133
+ site_key = options.delete(:site_key)
134
+ hl = options.delete(:hl)
135
+ onload = options.delete(:onload)
136
+ render = options.delete(:render)
137
+ script_async = options.delete(:script_async)
138
+ script_defer = options.delete(:script_defer)
139
+ nonce = options.delete(:nonce)
140
+ skip_script = (options.delete(:script) == false) || (options.delete(:external_script) == false)
141
+ ui = options.delete(:ui)
142
+ options.delete(:ignore_no_element)
143
+
144
+ data_attribute_keys = [:badge, :theme, :type, :callback, :expired_callback, :error_callback, :size]
145
+ data_attribute_keys << :tabindex unless ui == :button
146
+ data_attributes = {}
147
+ data_attribute_keys.each do |data_attribute|
148
+ value = options.delete(data_attribute)
149
+ data_attributes["data-#{data_attribute.to_s.tr('_', '-')}"] = value if value
150
+ end
151
+
152
+ unless Recaptcha.skip_env?(env)
153
+ site_key ||= Recaptcha.configuration.site_key!
154
+ script_url = Recaptcha.configuration.api_server_url
155
+ query_params = hash_to_query(
156
+ hl: hl,
157
+ onload: onload,
158
+ render: render
159
+ )
160
+ script_url += "?#{query_params}" unless query_params.empty?
161
+ async_attr = "async" if script_async != false
162
+ defer_attr = "defer" if script_defer != false
163
+ nonce_attr = " nonce='#{nonce}'" if nonce
164
+ html << %(<script src="#{script_url}" #{async_attr} #{defer_attr} #{nonce_attr}></script>\n) unless skip_script
165
+ fallback_uri = %(#{script_url.chomp(".js")}/fallback?k=#{site_key})
166
+ attributes["data-sitekey"] = site_key
167
+ attributes.merge! data_attributes
168
+ end
169
+
170
+ # The remaining options will be added as attributes on the tag.
171
+ attributes["class"] = "g-recaptcha #{class_attribute}"
172
+ tag_attributes = attributes.merge(options).map { |k, v| %(#{k}="#{v}") }.join(" ")
173
+
174
+ [html, tag_attributes, fallback_uri]
175
+ end
176
+
177
+ # v3
178
+
179
+ # Renders a script that calls `grecaptcha.execute` or
180
+ # `grecaptcha.enterprise.execute` for the given `site_key` and `action` and
181
+ # calls the `callback` with the resulting response token.
182
+ private_class_method def self.recaptcha_v3_inline_script(site_key, action, callback, id, options = {})
183
+ nonce = options[:nonce]
184
+ nonce_attr = " nonce='#{nonce}'" if nonce
185
+
186
+ <<-HTML
187
+ <script#{nonce_attr}>
188
+ // Define function so that we can call it again later if we need to reset it
189
+ // This executes reCAPTCHA and then calls our callback.
190
+ function #{recaptcha_v3_execute_function_name(action)}() {
191
+ #{recaptcha_ready_method_name}(function() {
192
+ #{recaptcha_execute_method_name}('#{site_key}', {action: '#{action}'}).then(function(token) {
193
+ #{callback}('#{id}', token)
194
+ });
195
+ });
196
+ };
197
+ // Invoke immediately
198
+ #{recaptcha_v3_execute_function_name(action)}()
199
+
200
+ // Async variant so you can await this function from another async function (no need for
201
+ // an explicit callback function then!)
202
+ // Returns a Promise that resolves with the response token.
203
+ async function #{recaptcha_v3_async_execute_function_name(action)}() {
204
+ return new Promise((resolve, reject) => {
205
+ #{recaptcha_ready_method_name}(async function() {
206
+ resolve(await #{recaptcha_execute_method_name}('#{site_key}', {action: '#{action}'}))
207
+ });
208
+ })
209
+ };
210
+
211
+ #{recaptcha_v3_define_default_callback(callback, options) if recaptcha_v3_define_default_callback?(callback, action, options)}
212
+ </script>
213
+ HTML
214
+ end
215
+
216
+ private_class_method def self.recaptcha_v3_onload_script(site_key, action, callback, id, options = {})
217
+ nonce = options[:nonce]
218
+ nonce_attr = " nonce='#{nonce}'" if nonce
219
+
220
+ <<-HTML
221
+ <script#{nonce_attr}>
222
+ function #{recaptcha_v3_execute_function_name(action)}() {
223
+ #{recaptcha_ready_method_name}(function() {
224
+ #{recaptcha_execute_method_name}('#{site_key}', {action: '#{action}'}).then(function(token) {
225
+ #{callback}('#{id}', token)
226
+ });
227
+ });
228
+ };
229
+ #{recaptcha_v3_define_default_callback(callback, options) if recaptcha_v3_define_default_callback?(callback, action, options)}
230
+ </script>
231
+ HTML
232
+ end
233
+
234
+ private_class_method def self.recaptcha_v3_inline_script?(options)
235
+ !Recaptcha.skip_env?(options[:env]) &&
236
+ options[:script] != false &&
237
+ options[:inline_script] != false
238
+ end
239
+
240
+ private_class_method def self.recaptcha_v3_define_default_callback(callback, options)
241
+ <<-HTML
242
+ var #{callback} = function(id, token) {
243
+ var element = document.getElementById(id);
244
+ #{element_check_condition(options)} element.value = token;
245
+ }
246
+ HTML
247
+ end
248
+
249
+ # Returns true if we should be adding the default callback.
250
+ # That is, if the given callback name is the default callback name (for the given action) and we
251
+ # are not skipping inline scripts for any reason.
252
+ private_class_method def self.recaptcha_v3_define_default_callback?(callback, action, options)
253
+ callback == recaptcha_v3_default_callback_name(action) &&
254
+ recaptcha_v3_inline_script?(options)
255
+ end
256
+
257
+ # Returns the name of the JavaScript function that actually executes the
258
+ # reCAPTCHA code (calls `grecaptcha.execute` or
259
+ # `grecaptcha.enterprise.execute`). You can call it again later to reset it.
260
+ def self.recaptcha_v3_execute_function_name(action)
261
+ "executeRecaptchaFor#{sanitize_action_for_js(action)}"
262
+ end
263
+
264
+ # Returns the name of an async JavaScript function that executes the reCAPTCHA code.
265
+ def self.recaptcha_v3_async_execute_function_name(action)
266
+ "#{recaptcha_v3_execute_function_name(action)}Async"
267
+ end
268
+
269
+ def self.recaptcha_v3_default_callback_name(action)
270
+ "setInputWithRecaptchaResponseTokenFor#{sanitize_action_for_js(action)}"
271
+ end
272
+
273
+ # v2
274
+
275
+ private_class_method def self.default_callback(options = {})
276
+ nonce = options[:nonce]
277
+ nonce_attr = " nonce='#{nonce}'" if nonce
278
+ selector_attr = options[:id] ? "##{options[:id]}" : ".g-recaptcha"
279
+
280
+ <<-HTML
281
+ <script#{nonce_attr}>
282
+ var invisibleRecaptchaSubmit = function () {
283
+ var closestForm = function (ele) {
284
+ var curEle = ele.parentNode;
285
+ while (curEle.nodeName !== 'FORM' && curEle.nodeName !== 'BODY'){
286
+ curEle = curEle.parentNode;
287
+ }
288
+ return curEle.nodeName === 'FORM' ? curEle : null
289
+ };
290
+
291
+ var el = document.querySelector("#{selector_attr}")
292
+ if (!!el) {
293
+ var form = closestForm(el);
294
+ if (form) {
295
+ form.submit();
296
+ }
297
+ }
298
+ };
299
+ </script>
300
+ HTML
301
+ end
302
+
303
+ def self.recaptcha_execute_method_name
304
+ Recaptcha.configuration.enterprise ? "grecaptcha.enterprise.execute" : "grecaptcha.execute"
305
+ end
306
+
307
+ def self.recaptcha_ready_method_name
308
+ Recaptcha.configuration.enterprise ? "grecaptcha.enterprise.ready" : "grecaptcha.ready"
309
+ end
310
+
311
+ private_class_method def self.default_callback_required?(options)
312
+ options[:callback] == 'invisibleRecaptchaSubmit' &&
313
+ !Recaptcha.skip_env?(options[:env]) &&
314
+ options[:script] != false &&
315
+ options[:inline_script] != false
316
+ end
317
+
318
+ # Returns a camelized string that is safe for use in a JavaScript variable/function name.
319
+ # sanitize_action_for_js('my/action') => 'MyAction'
320
+ private_class_method def self.sanitize_action_for_js(action)
321
+ action.to_s.gsub(/\W/, '_').split(/\/|_/).map(&:capitalize).join
322
+ end
323
+
324
+ # Returns a dasherized string that is safe for use as an HTML ID
325
+ # dasherize_action('my/action') => 'my-action'
326
+ private_class_method def self.dasherize_action(action)
327
+ action.to_s.gsub(/\W/, '-').tr('_', '-')
328
+ end
329
+
330
+ private_class_method def self.hash_to_query(hash)
331
+ hash.delete_if { |_, val| val.nil? || val.empty? }.to_a.map { |pair| pair.join('=') }.join('&')
332
+ end
333
+
334
+ private_class_method def self.element_check_condition(options)
335
+ options[:ignore_no_element] ? "if (element !== null)" : ""
336
+ end
337
+ end
338
+ end
@@ -3,13 +3,33 @@
3
3
  module Recaptcha
4
4
  class Railtie < Rails::Railtie
5
5
  ActiveSupport.on_load(:action_view) do
6
- require 'recaptcha/client_helper'
7
- include Recaptcha::ClientHelper
6
+ include Recaptcha::Adapters::ViewMethods
8
7
  end
9
8
 
10
9
  ActiveSupport.on_load(:action_controller) do
11
- require 'recaptcha/verify'
12
- include Recaptcha::Verify
10
+ include Recaptcha::Adapters::ControllerMethods
11
+ end
12
+
13
+ initializer 'recaptcha' do |app|
14
+ Recaptcha::Railtie.instance_eval do
15
+ pattern = pattern_from app.config.i18n.available_locales
16
+
17
+ add("rails/locales/#{pattern}.yml")
18
+ end
19
+ end
20
+
21
+ class << self
22
+ protected
23
+
24
+ def add(pattern)
25
+ files = Dir[File.join(File.dirname(__FILE__), '../..', pattern)]
26
+ I18n.load_path.concat(files)
27
+ end
28
+
29
+ def pattern_from(args)
30
+ array = Array(args || [])
31
+ array.blank? ? '*' : "{#{array.join ','}}"
32
+ end
13
33
  end
14
34
  end
15
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Recaptcha
4
- VERSION = '4.14.0'
4
+ VERSION = '5.14.0'
5
5
  end