turnstile-ruby 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c562e5825290bbab8c92309f410fdeedbf3f1a4a87abd3243130886f8ffc07e
4
- data.tar.gz: 2dd53d7bc2dde10c1493b8a5ab7be75ec96e4d107055052e3c511c23ef946841
3
+ metadata.gz: 69f9f7ac9e416004547bd10b0a8c9b5bef6e40002496e29cd991c77322e6d6a7
4
+ data.tar.gz: 88c81cfcd7d560a4eab7cf66fe485516f1ce75a2f1b18149225adc451dd2bff2
5
5
  SHA512:
6
- metadata.gz: ed59df2922741050d4632c697cf576a48379014ea62ca4f5bab12f80e71757d5b97dceb556447bd3c984f0fb80c4ec3f8f6aac451b307d90d0fbadd8354b9e1c
7
- data.tar.gz: c546b14454b5cb81788d15e716e1cb4ab1d13687dbbf07bc6730c7a61e8db156cf5e7ae1b744c30881b51f8e09cfe718bb23e4331e808292a1284426241e1006
6
+ metadata.gz: eed815205816d06577a046f9ce71daca0b84ebc1ef32bf1eb0a552afdf3678e3aa3463390c9999f361d06b33de0853e84198ac2d98b3e76e681f1d78f4ea8cb3
7
+ data.tar.gz: 40e04813942a68657a473082f4c21ca017b404a5ed6f5060f8774c72edfe72c0d1ab7a36e06eee24da9098150de27e97fb4e81f2ba106f17cea09baf551ca7f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - `Turnstile.verify(token, ...)` returns a `Turnstile::Response` (`success?`, `error_codes`, `hostname`, `action`,
8
+ `cdata`, `challenge_ts`, `metadata`, `to_h`) for use outside of controllers.
9
+ - Turbo (Hotwire) support: widgets render again after Turbo Drive visits, frame renders and cache restores. On by
10
+ default, turn it off with `turbo: false` or `config.turbo = false`.
11
+ - CSP nonces: `nonce:` is added to every script tag, and the Rails helpers pick up `content_security_policy_nonce`
12
+ automatically.
13
+ - Widget options `appearance`, `execution`, `tabindex`, `refresh_timeout`, `feedback_enabled`,
14
+ `before_interactive_callback`, `after_interactive_callback`, `unsupported_callback`, `offlabel_show_privacy` and
15
+ `offlabel_show_help`.
16
+ - `config.timeout`, `Turnstile.site_key`, `Turnstile.secret_key` and the `idempotency_key:` verify option.
17
+ - `Turnstile::Testing` (`require "turnstile/testing"`) to stub verification results in your own tests.
18
+ - `lib/turnstile-ruby.rb`, so a plain `gem "turnstile-ruby"` is auto-required by Bundler.
19
+ - A spec suite, run on Ruby 2.7 to 4.0.
20
+
21
+ ### Changed
22
+
23
+ - `invisible_turnstile_tags` is reimplemented. It used to render the widget inside the submit button and submit the
24
+ form as soon as the page loaded. It now renders a hidden widget next to the button and runs the challenge when the
25
+ form is submitted. The button no longer gets the `cf-turnstile` class.
26
+ - The `hl:` option sets `data-language` instead of an unused `hl` query parameter on the script.
27
+ - `verify_turnstile!` raises `Turnstile::VerifyError` with a readable message instead of the reply hash.
28
+ - `turnstile_tags` emits a small inline script for Turbo support (see above).
29
+ - Ruby 2.7 or newer is required.
30
+
31
+ ### Fixed
32
+
33
+ - A missing `cf-turnstile-response` parameter raised `TurnstileError` (a 500) instead of failing verification.
34
+ - `verify_turnstile(model: record)` called `record.new` and raised when verification failed. A `model:` without
35
+ `errors` (for example a class) falls back to the flash.
36
+ - The `timeout:` option was ignored.
37
+ - A non-JSON reply from Cloudflare raised instead of being handled like a timeout.
38
+ - Attribute values were not HTML-escaped.
39
+ - Options such as `inline_script` and `external_script` leaked into the widget's HTML attributes.
40
+ - `response_field: false` was dropped instead of rendered. Other options still leave out `false` and `nil`.
41
+ - `Turnstile::Helpers.to_error_message` raised `NoMethodError` for unknown keys.
42
+ - `LICENSE.txt` was missing from the packaged gem.
43
+
3
44
  ## [0.1.0] - 2023-02-23
4
45
 
5
46
  - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Kishan Verma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -1,21 +1,26 @@
1
1
  # Turnstile Ruby
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/turnstile-ruby.svg)](https://badge.fury.io/rb/turnstile-ruby)
4
+ [![CI](https://github.com/urkkv/turnstile-ruby/actions/workflows/main.yml/badge.svg)](https://github.com/urkkv/turnstile-ruby/actions/workflows/main.yml)
4
5
 
5
- A simple Ruby client for verifying [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) tokens.
6
+ [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) for Ruby: view helpers for the widget, controller
7
+ helpers for verification, and a small client for the siteverify API.
6
8
  Turnstile is a CAPTCHA alternative from Cloudflare, similar to Google reCAPTCHA, but lightweight and privacy-friendly.
7
9
 
8
- This gem makes it easy to integrate Turnstile into Ruby and Rails applications.
10
+ It works in **Rails, Sinatra, any Rack app and plain Ruby**, and has no runtime dependencies.
9
11
 
10
12
  ---
11
13
 
12
14
  ## ✨ Features
13
15
 
14
- - Verify Turnstile tokens with Cloudflare’s API
15
- - Lightweight, no heavy dependencies
16
- - Works with plain Ruby and Ruby on Rails
17
- - Provides meaningful error codes and success flags
18
- - Simple configuration via environment variables or initializer
16
+ - `turnstile_tags` and `verify_turnstile` helpers, added to Rails automatically
17
+ - `Turnstile.verify` for jobs, APIs, Sinatra and plain Ruby, returning error codes and the reply details
18
+ - Works with Turbo (Hotwire): widgets keep rendering across Turbo visits
19
+ - CSP friendly: script tags carry your nonce, picked up from Rails automatically
20
+ - Invisible flow that runs the challenge when the form is submitted
21
+ - Translated error messages (en, fr, ja, nl), written to the flash or your model's errors
22
+ - Test helpers to stub passing and failing verifications
23
+ - No runtime dependencies, Ruby 2.7+
19
24
 
20
25
  ---
21
26
 
@@ -33,81 +38,166 @@ And install:
33
38
  bundle install
34
39
  ```
35
40
 
36
- Or install directly with:
37
-
38
- ```bash
39
- gem install turnstile-ruby
40
- ```
41
-
42
41
  ---
43
42
 
44
43
  ## ⚙️ Configuration
45
44
 
46
- Set your **Cloudflare Turnstile Site Key** and **Secret Key** in environment variables:
45
+ Get a site key and a secret key from the [Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/turnstile)
46
+ and set them in the environment:
47
47
 
48
48
  ```bash
49
49
  export TURNSTILE_SITE_KEY="your_site_key"
50
50
  export TURNSTILE_SECRET_KEY="your_secret_key"
51
51
  ```
52
52
 
53
- For Rails, you can create an initializer (`config/initializers/turnstile.rb`):
53
+ Or configure them in code, for Rails in `config/initializers/turnstile.rb`:
54
54
 
55
55
  ```ruby
56
56
  Turnstile.configure do |config|
57
57
  config.site_key = ENV["TURNSTILE_SITE_KEY"]
58
58
  config.secret_key = ENV["TURNSTILE_SECRET_KEY"]
59
- config.timeout = 5 # optional, in seconds
59
+ config.timeout = 5 # optional, seconds to wait for Cloudflare (default 3)
60
60
  end
61
61
  ```
62
62
 
63
+ | Option | Default | |
64
+ |---|---|---|
65
+ | `site_key`, `secret_key` | `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` | Your keys |
66
+ | `timeout` | `3` | Seconds to wait for Cloudflare when verifying |
67
+ | `skip_verify_env` | `["test", "cucumber", "rspec"]` | Environments where verification always passes |
68
+ | `handle_timeouts_gracefully` | `true` | Fail verification with a message when Cloudflare is unreachable, instead of raising |
69
+ | `hostname` | `nil` | A string or callable the reply's hostname has to match |
70
+ | `turbo` | `true` | Render widgets again after Turbo visits |
71
+ | `proxy` | `nil` | HTTP proxy URL for the verification request |
72
+ | `response_limit` | `2048` | Maximum accepted token length |
73
+
74
+ For local development Cloudflare has [dummy keys](https://developers.cloudflare.com/turnstile/troubleshooting/testing/)
75
+ that work on any host: site key `1x00000000000000000000AA` with secret key `1x0000000000000000000000000000000AA`
76
+ always passes.
77
+
63
78
  ---
64
79
 
65
80
  ## 🛠 Usage
66
81
 
67
- ### Rails Views
82
+ ### Rails
68
83
 
69
- Embed the Turnstile widget:
84
+ Add the widget to a form:
70
85
 
71
86
  ```erb
72
- <form action="/signup" method="POST">
73
- <!-- Your form fields -->
87
+ <%= form_with model: @user do |form| %>
88
+ <%= form.text_field :email %>
89
+ <%= turnstile_tags %>
90
+ <%= form.submit %>
91
+ <% end %>
92
+ ```
93
+
94
+ And verify it in the controller:
95
+
96
+ ```ruby
97
+ def create
98
+ @user = User.new(user_params)
99
+
100
+ if verify_turnstile(model: @user) && @user.save
101
+ redirect_to @user
102
+ else
103
+ render :new, status: :unprocessable_entity
104
+ end
105
+ end
106
+ ```
107
+
108
+ `verify_turnstile` returns `true` or `false`. On failure it adds an error to the model, or sets
109
+ `flash[:turnstile_error]` when no model is given. A missing token fails verification, it does not raise.
110
+
111
+ | Option | |
112
+ |---|---|
113
+ | `model:` | Record that gets the error message |
114
+ | `attribute:` | Attribute for the error, `:base` by default |
115
+ | `message:` | Custom error message |
116
+ | `action:` / `hostname:` | Fail unless the reply matches |
117
+ | `timeout:` | Seconds to wait, overriding the configuration |
118
+ | `secret_key:` | Secret key for this call |
119
+ | `response:` | Token to verify, `params["cf-turnstile-response"]` by default |
120
+ | `skip_remote_ip: true` | Do not send the visitor's IP to Cloudflare |
121
+
122
+ `verify_turnstile!` raises `Turnstile::VerifyError` instead of returning `false`, and `turnstile_reply` gives you
123
+ Cloudflare's raw reply afterwards.
124
+
125
+ ### Widget options
126
+
127
+ `turnstile_tags` accepts the
128
+ [widget configuration](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/widget-configurations/)
129
+ in snake_case, and passes anything else through as HTML attributes:
130
+
131
+ ```erb
132
+ <%= turnstile_tags theme: "dark", size: "flexible", action: "signup", appearance: "interaction-only", class: "mt-4" %>
133
+ ```
74
134
 
75
- <div class="cf-turnstile"
76
- data-sitekey="<%= Turnstile.site_key %>">
77
- </div>
135
+ `action`, `cdata`, `theme`, `language`, `size`, `appearance`, `execution`, `tabindex`, `retry`, `retry_interval`,
136
+ `refresh_expired`, `refresh_timeout`, `response_field`, `response_field_name`, `feedback_enabled`,
137
+ `offlabel_show_privacy`, `offlabel_show_help`, `callback`, `error_callback`, `expired_callback`, `timeout_callback`,
138
+ `before_interactive_callback`, `after_interactive_callback` and `unsupported_callback` are supported.
78
139
 
79
- <button type="submit">Submit</button>
80
- </form>
140
+ | Helper option | |
141
+ |---|---|
142
+ | `site_key:` | Site key for this widget |
143
+ | `nonce:` | CSP nonce for the script tags (automatic in Rails) |
144
+ | `turbo: false` | Leave out the Turbo support script |
145
+ | `script: false` | Render no script tags, for when you load `api.js` yourself |
146
+ | `onload:`, `render:` | Query parameters for `api.js`, for [explicit rendering](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicit-rendering) |
147
+ | `script_async: false`, `script_defer: false` | Drop `async` / `defer` from the script tag |
81
148
 
82
- <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
149
+ ### Turbo
150
+
151
+ Nothing to set up. `api.js` only scans the page once, so with Turbo Drive a widget would stay empty after the first
152
+ visit. `turnstile_tags` therefore includes a small inline script that renders widgets after Turbo visits, frame
153
+ renders and cache restores. If your Content Security Policy blocks inline scripts and you pass no nonce, the widget
154
+ still works on full page loads.
155
+
156
+ ### Content Security Policy
157
+
158
+ Allow `https://challenges.cloudflare.com` in `script-src` and `frame-src`. In Rails the helpers add your
159
+ `content_security_policy_nonce` to their script tags; elsewhere pass `nonce:` yourself.
160
+
161
+ ### Invisible flow
162
+
163
+ ```erb
164
+ <%= form_with url: signup_path do |form| %>
165
+ <%= form.text_field :email %>
166
+ <%= invisible_turnstile_tags text: "Sign up", class: "btn" %>
167
+ <% end %>
83
168
  ```
84
169
 
85
- ### Rails Controller
170
+ This renders a submit button and a widget that stays hidden unless Cloudflare needs the visitor to interact. The
171
+ challenge runs when the form is submitted, and the form is sent once the token arrives. Pass `ui: :input` for an
172
+ `<input type="submit">`, or `callback: "myFunction"` to receive the token yourself instead of submitting the form.
86
173
 
87
- Verify the token on form submission:
174
+ ### Sinatra
88
175
 
89
176
  ```ruby
90
- def create
91
- token = params["cf-turnstile-response"]
177
+ require "sinatra"
178
+ require "turnstile"
92
179
 
93
- result = Turnstile.verify(token, remote_ip: request.remote_ip)
180
+ helpers Turnstile::Adapters::ViewMethods, Turnstile::Adapters::ControllerMethods
94
181
 
95
- if result.success?
96
- # proceed with signup
97
- else
98
- flash[:error] = "Turnstile verification failed: #{result.error_codes.join(", ")}"
99
- render :new
100
- end
182
+ get "/" do
183
+ %(<form action="/verify" method="post">#{turnstile_tags}<button>Submit</button></form>)
184
+ end
185
+
186
+ post "/verify" do
187
+ verify_turnstile ? "Verified" : halt(422, "Verification failed")
101
188
  end
102
189
  ```
103
190
 
104
- ### Plain Ruby Example
191
+ There is a runnable version in [`demo/sinatra`](demo/sinatra).
192
+
193
+ ### Plain Ruby
194
+
195
+ `Turnstile.verify` works anywhere: API endpoints, background jobs, other frameworks.
105
196
 
106
197
  ```ruby
107
198
  require "turnstile"
108
199
 
109
- token = "client-submitted-token"
110
- result = Turnstile.verify(token)
200
+ result = Turnstile.verify(token, remote_ip: request.ip)
111
201
 
112
202
  if result.success?
113
203
  puts "Verification passed!"
@@ -116,16 +206,38 @@ else
116
206
  end
117
207
  ```
118
208
 
209
+ It returns a `Turnstile::Response`:
210
+
211
+ - `success?` → `true` or `false`
212
+ - `error_codes` → Cloudflare's [error codes](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes),
213
+ plus `hostname-mismatch` / `action-mismatch` when you asked to validate those
214
+ - `hostname`, `action`, `cdata`, `challenge_ts`, `metadata` → details of the challenge
215
+ - `to_h` → the raw reply
216
+
217
+ Options: `remote_ip:`, `secret_key:`, `timeout:`, `hostname:`, `action:` and `idempotency_key:`.
218
+
219
+ A missing token returns a failed response without calling Cloudflare. Unlike `verify_turnstile`, this method does
220
+ not skip the test environment, and it raises `Timeout::Error` or `Turnstile::UnreachableError` when Cloudflare
221
+ cannot be reached.
222
+
119
223
  ---
120
224
 
121
- ## ✅ Response Object
225
+ ## 🧪 Testing your app
122
226
 
123
- `Turnstile.verify` returns a `Turnstile::Response` object with:
227
+ In the `test` environment `verify_turnstile` always passes and `turnstile_tags` renders an empty container, so your
228
+ tests never call Cloudflare. To test what happens when verification fails:
229
+
230
+ ```ruby
231
+ require "turnstile/testing"
232
+
233
+ Turnstile::Testing.with_failure do
234
+ post signups_path, params: { user: { email: "a@example.com" } }
235
+ expect(response).to have_http_status(:unprocessable_entity)
236
+ end
237
+ ```
124
238
 
125
- - `success?` → `true` or `false`
126
- - `error_codes` → array of error codes
127
- - `hostname` → hostname (if provided)
128
- - `challenge_ts` → challenge timestamp (if provided)
239
+ `Turnstile::Testing.success!`, `failure!("timeout-or-duplicate")` and `reset!` do the same without a block; call
240
+ `reset!` after each test when you use them.
129
241
 
130
242
  ---
131
243
 
@@ -139,28 +251,29 @@ cd turnstile-ruby
139
251
  bundle install
140
252
  ```
141
253
 
142
- Run tests:
254
+ Run the specs and RuboCop:
143
255
 
144
256
  ```bash
145
- bundle exec rspec
257
+ bundle exec rake
146
258
  ```
147
259
 
148
260
  ---
149
261
 
150
262
  ## 📜 License
151
263
 
152
- This project is licensed under the [MIT License](LICENSE).
264
+ This project is licensed under the [MIT License](LICENSE.txt).
153
265
 
154
266
  ---
155
267
 
156
268
  ## 🙌 Contributing
157
269
 
158
- Bug reports and pull requests are welcome at
270
+ Bug reports and pull requests are welcome at
159
271
  [https://github.com/urkkv/turnstile-ruby](https://github.com/urkkv/turnstile-ruby).
160
272
 
161
273
  ---
162
274
 
163
275
  ## 🔗 Resources
164
276
 
165
- - [Cloudflare Turnstile Documentation](https://developers.cloudflare.com/turnstile/)
166
- - [RubyGems.org – turnstile-ruby](https://rubygems.org/gems/turnstile-ruby)
277
+ - [Cloudflare Turnstile Documentation](https://developers.cloudflare.com/turnstile/)
278
+ - [RubyGems.org – turnstile-ruby](https://rubygems.org/gems/turnstile-ruby)
279
+ - [Changelog](CHANGELOG.md)
@@ -8,28 +8,17 @@ module Turnstile
8
8
  # Your private API can be specified in the +options+ hash or preferably
9
9
  # using the Configuration.
10
10
  def verify_turnstile(options = {})
11
- options = {model: options} unless options.is_a? Hash
12
- return true if Turnstile.skip_env?(options[:env])
11
+ options = { model: options } unless options.is_a? Hash
12
+ return true if Turnstile.stubbed_response.nil? && Turnstile.skip_env?(options[:env])
13
13
 
14
14
  model = options[:model]
15
15
  attribute = options.fetch(:attribute, :base)
16
- turnstile_response = options[:response] || params['cf-turnstile-response']
17
16
 
18
17
  begin
19
- verified = if Turnstile.invalid_response?(turnstile_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
18
+ result = Turnstile.verify(options[:response] || params["cf-turnstile-response"], turnstile_options(options))
19
+ @_turnstile_reply = result.to_h
26
20
 
27
- success, @_turnstile_reply =
28
- Turnstile.verify_via_api_call(turnstile_response, options.merge(with_reply: true))
29
- success
30
- end
31
-
32
- if verified
21
+ if result.success?
33
22
  flash.delete(:turnstile_error) if turnstile_flash_supported? && !model
34
23
  true
35
24
  else
@@ -40,33 +29,47 @@ module Turnstile
40
29
  )
41
30
  false
42
31
  end
43
- rescue Timeout::Error
44
- if Turnstile.configuration.handle_timeouts_gracefully
45
- turnstile_error(
46
- model,
47
- attribute,
48
- options.fetch(:message) { Turnstile::Helpers.to_error_message(:turnstile_unreachable) }
49
- )
50
- false
51
- else
52
- raise TurnstileError, 'Turnstile unreachable.'
53
- end
32
+ rescue Timeout::Error, UnreachableError
33
+ raise TurnstileError, "Turnstile unreachable." unless Turnstile.configuration.handle_timeouts_gracefully
34
+
35
+ turnstile_error(
36
+ model,
37
+ attribute,
38
+ options.fetch(:message) { Turnstile::Helpers.to_error_message(:turnstile_unreachable) }
39
+ )
40
+ false
41
+ rescue TurnstileError
42
+ raise
54
43
  rescue StandardError => e
55
44
  raise TurnstileError, e.message, e.backtrace
56
45
  end
57
46
  end
58
47
 
59
48
  def verify_turnstile!(options = {})
60
- verify_turnstile(options) || raise(VerifyError, turnstile_reply)
49
+ return true if verify_turnstile(options)
50
+
51
+ raise VerifyError, "Turnstile verification failed: #{Array(turnstile_reply.to_h["error-codes"]).join(", ")}"
61
52
  end
62
53
 
54
+ # The raw siteverify reply of the last verify_turnstile call.
63
55
  def turnstile_reply
64
56
  @_turnstile_reply if defined?(@_turnstile_reply)
65
57
  end
66
58
 
59
+ def turnstile_options(options)
60
+ return options if options[:skip_remote_ip] || options.key?(:remote_ip)
61
+
62
+ remote_ip = if request.respond_to?(:remote_ip)
63
+ request.remote_ip
64
+ elsif request.respond_to?(:ip)
65
+ request.ip
66
+ end
67
+ remote_ip ? options.merge(remote_ip: remote_ip.to_s) : options
68
+ end
69
+
67
70
  def turnstile_error(model, attribute, message)
68
- if model
69
- model.new.errors.add(attribute, message)
71
+ if model.respond_to?(:errors)
72
+ model.errors.add(attribute, message)
70
73
  elsif turnstile_flash_supported?
71
74
  flash[:turnstile_error] = message
72
75
  end
@@ -75,7 +78,6 @@ module Turnstile
75
78
  def turnstile_flash_supported?
76
79
  request.respond_to?(:format) && request.format == :html && respond_to?(:flash)
77
80
  end
78
-
79
81
  end
80
82
  end
81
83
  end
@@ -3,14 +3,24 @@
3
3
  module Turnstile
4
4
  module Adapters
5
5
  module ViewMethods
6
- # Renders a turnstile [Checkbox](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) widget
6
+ # Renders a Turnstile [widget](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/)
7
7
  def turnstile_tags(options = {})
8
- ::Turnstile::Helpers.turnstile_tags(options)
8
+ ::Turnstile::Helpers.turnstile_tags(turnstile_nonce(options))
9
9
  end
10
10
 
11
- # Renders a Turnstile [Invisible Turnstile captcha](https://developers.cloudflare.com/turnstile/reference/widget-types/#invisible)
11
+ # Renders a submit button that runs an [invisible](https://developers.cloudflare.com/turnstile/concepts/widget/)
12
+ # challenge when the form is submitted
12
13
  def invisible_turnstile_tags(options = {})
13
- ::Turnstile::Helpers.invisible_turnstile_tags(options)
14
+ ::Turnstile::Helpers.invisible_turnstile_tags(turnstile_nonce(options))
15
+ end
16
+
17
+ private
18
+
19
+ # Picks up the Rails CSP nonce unless one was passed in.
20
+ def turnstile_nonce(options)
21
+ return options if options.key?(:nonce) || !respond_to?(:content_security_policy_nonce)
22
+
23
+ options.merge(nonce: content_security_policy_nonce)
14
24
  end
15
25
  end
16
26
  end
@@ -25,30 +25,36 @@ module Turnstile
25
25
  # Setting the keys with this Configuration
26
26
  #
27
27
  # Turnstile.configure do |config|
28
- # config.site_key = '0x4AAAAAAAC1z764FTJAewGm'
29
- # config.secret_key = '0x4AAAAAAAC1z_XZkOcOamwjRONkb-xoXMU'
28
+ # config.site_key = ENV["TURNSTILE_SITE_KEY"]
29
+ # config.secret_key = ENV["TURNSTILE_SECRET_KEY"]
30
30
  # end
31
31
  #
32
32
  class Configuration
33
33
  DEFAULTS = {
34
- 'server_url' => 'https://challenges.cloudflare.com/turnstile/v0/api.js',
35
- 'verify_url' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
34
+ "server_url" => "https://challenges.cloudflare.com/turnstile/v0/api.js",
35
+ "verify_url" => "https://challenges.cloudflare.com/turnstile/v0/siteverify"
36
36
  }.freeze
37
37
 
38
38
  attr_accessor :default_env, :skip_verify_env, :proxy, :secret_key, :site_key, :handle_timeouts_gracefully,
39
- :hostname, :response_limit
39
+ :hostname, :response_limit, :timeout, :turbo
40
40
  attr_writer :api_server_url, :verify_url
41
41
 
42
42
  def initialize # :nodoc:
43
- @default_env = ENV['RAILS_ENV'] || ENV['RACK_ENV'] || (Rails.env if defined? Rails.env)
43
+ @default_env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || (Rails.env if defined? Rails.env)
44
44
  @skip_verify_env = %w[test cucumber rspec]
45
45
  @handle_timeouts_gracefully = true
46
46
 
47
- @secret_key = ENV['TURNSTILE_SECRET_KEY']
48
- @site_key = ENV['TURNSTILE_SITE_KEY']
47
+ @secret_key = ENV["TURNSTILE_SECRET_KEY"]
48
+ @site_key = ENV["TURNSTILE_SITE_KEY"]
49
49
 
50
- @verify_url = ENV['TURNSTILE_VERIFY_URL']
51
- @api_server_url = ENV['TURNSTILE_SERVER_URL']
50
+ @verify_url = ENV["TURNSTILE_VERIFY_URL"]
51
+ @api_server_url = ENV["TURNSTILE_SERVER_URL"]
52
+
53
+ # Seconds to wait for Cloudflare when verifying a token.
54
+ @timeout = Turnstile::DEFAULT_TIMEOUT
55
+
56
+ # Render widgets again after Turbo (Hotwire) navigations.
57
+ @turbo = true
52
58
 
53
59
  # Default response token size
54
60
  # https://developers.cloudflare.com/turnstile/frequently-asked-questions/#what-is-the-length-of-a-turnstile-token
@@ -64,11 +70,11 @@ module Turnstile
64
70
  end
65
71
 
66
72
  def api_server_url
67
- @api_server_url || DEFAULTS.fetch('server_url')
73
+ @api_server_url || DEFAULTS.fetch("server_url")
68
74
  end
69
75
 
70
76
  def verify_url
71
- @verify_url || DEFAULTS.fetch('verify_url')
77
+ @verify_url || DEFAULTS.fetch("verify_url")
72
78
  end
73
79
  end
74
80
  end
@@ -0,0 +1,60 @@
1
+ (function () {
2
+ var w = window, d = document;
3
+ if (w.turnstileRuby) { w.turnstileRuby.render(); return; }
4
+
5
+ var whenReady = function (fn) {
6
+ if (w.turnstile) { fn(); } else { setTimeout(function () { whenReady(fn); }, 50); }
7
+ };
8
+
9
+ // api.js only scans the page once, widgets added by a Turbo render have to be rendered here.
10
+ var render = function () {
11
+ if (!w.turnstile || d.readyState === "loading" || d.documentElement.hasAttribute("data-turbo-preview")) return;
12
+ d.querySelectorAll(".cf-turnstile").forEach(function (el) {
13
+ if (!el.childElementCount) w.turnstile.render(el);
14
+ });
15
+ };
16
+
17
+ // Pages that are cached or replaced must not keep a dead widget or a spent token.
18
+ var cleanup = function () {
19
+ d.querySelectorAll(".cf-turnstile, [data-turnstile-invisible]").forEach(function (el) {
20
+ if (!el.childElementCount) return;
21
+ try { w.turnstile.remove(el); } catch (e) {}
22
+ el.innerHTML = "";
23
+ });
24
+ };
25
+
26
+ var verified = function (el, form, token) {
27
+ var name = el.getAttribute("data-callback");
28
+ if (name && typeof w[name] === "function") { w[name](token); return; }
29
+ el.turnstileVerified = true;
30
+ var submitter = el.turnstileSubmitter;
31
+ if (form.requestSubmit) {
32
+ form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
33
+ } else {
34
+ form.submit();
35
+ }
36
+ };
37
+
38
+ // Invisible widgets: hold the submit back, run the challenge, then submit with a fresh token.
39
+ var onSubmit = function (event) {
40
+ var form = event.target;
41
+ var el = form.querySelector && form.querySelector("[data-turnstile-invisible][data-sitekey]");
42
+ if (!el) return;
43
+ if (el.turnstileVerified) { el.turnstileVerified = false; return; }
44
+ event.preventDefault();
45
+ event.stopPropagation();
46
+ el.turnstileSubmitter = event.submitter;
47
+ whenReady(function () {
48
+ if (el.childElementCount) { w.turnstile.reset(el); return; }
49
+ w.turnstile.render(el, { callback: function (token) { verified(el, form, token); } });
50
+ });
51
+ };
52
+
53
+ d.addEventListener("turbo:render", render);
54
+ d.addEventListener("turbo:frame-render", render);
55
+ d.addEventListener("turbo:before-cache", cleanup);
56
+ d.addEventListener("turbo:before-render", cleanup);
57
+ d.addEventListener("submit", onSubmit, true);
58
+ w.turnstileRuby = { render: render };
59
+ render();
60
+ })();