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 +4 -4
- data/CHANGELOG.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -52
- data/lib/turnstile/adapters/controller_methods.rb +33 -31
- data/lib/turnstile/adapters/view_methods.rb +14 -4
- data/lib/turnstile/configuration.rb +18 -12
- data/lib/turnstile/helper.js +60 -0
- data/lib/turnstile/helpers.rb +105 -98
- data/lib/turnstile/rails.rb +2 -2
- data/lib/turnstile/railtie.rb +3 -3
- data/lib/turnstile/response.rb +53 -0
- data/lib/turnstile/testing.rb +50 -0
- data/lib/turnstile/version.rb +1 -1
- data/lib/turnstile-ruby.rb +4 -0
- data/lib/turnstile.rb +80 -29
- data/sig/turnstile.rbs +68 -0
- metadata +23 -151
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 69f9f7ac9e416004547bd10b0a8c9b5bef6e40002496e29cd991c77322e6d6a7
|
|
4
|
+
data.tar.gz: 88c81cfcd7d560a4eab7cf66fe485516f1ce75a2f1b18149225adc451dd2bff2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/turnstile-ruby)
|
|
4
|
+
[](https://github.com/urkkv/turnstile-ruby/actions/workflows/main.yml)
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
15
|
-
-
|
|
16
|
-
- Works with
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
82
|
+
### Rails
|
|
68
83
|
|
|
69
|
-
|
|
84
|
+
Add the widget to a form:
|
|
70
85
|
|
|
71
86
|
```erb
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
### Sinatra
|
|
88
175
|
|
|
89
176
|
```ruby
|
|
90
|
-
|
|
91
|
-
|
|
177
|
+
require "sinatra"
|
|
178
|
+
require "turnstile"
|
|
92
179
|
|
|
93
|
-
|
|
180
|
+
helpers Turnstile::Adapters::ViewMethods, Turnstile::Adapters::ControllerMethods
|
|
94
181
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
225
|
+
## 🧪 Testing your app
|
|
122
226
|
|
|
123
|
-
`
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
254
|
+
Run the specs and RuboCop:
|
|
143
255
|
|
|
144
256
|
```bash
|
|
145
|
-
bundle exec
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
29
|
-
# config.secret_key =
|
|
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
|
-
|
|
35
|
-
|
|
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[
|
|
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[
|
|
48
|
-
@site_key = ENV[
|
|
47
|
+
@secret_key = ENV["TURNSTILE_SECRET_KEY"]
|
|
48
|
+
@site_key = ENV["TURNSTILE_SITE_KEY"]
|
|
49
49
|
|
|
50
|
-
@verify_url = ENV[
|
|
51
|
-
@api_server_url = ENV[
|
|
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(
|
|
73
|
+
@api_server_url || DEFAULTS.fetch("server_url")
|
|
68
74
|
end
|
|
69
75
|
|
|
70
76
|
def verify_url
|
|
71
|
-
@verify_url || DEFAULTS.fetch(
|
|
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
|
+
})();
|