cloudflare-turnstile-rails 0.8.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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +32 -0
  3. data/Appraisals +65 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +249 -0
  6. data/Rakefile +10 -0
  7. data/lib/cloudflare/turnstile/rails/assets/javascripts/cloudflare_turnstile_helper.js +40 -0
  8. data/lib/cloudflare/turnstile/rails/configuration.rb +41 -0
  9. data/lib/cloudflare/turnstile/rails/constants/cloudflare.rb +19 -0
  10. data/lib/cloudflare/turnstile/rails/constants/error_codes.rb +27 -0
  11. data/lib/cloudflare/turnstile/rails/constants/error_messages.rb +22 -0
  12. data/lib/cloudflare/turnstile/rails/controller_methods.rb +37 -0
  13. data/lib/cloudflare/turnstile/rails/engine.rb +17 -0
  14. data/lib/cloudflare/turnstile/rails/helpers.rb +35 -0
  15. data/lib/cloudflare/turnstile/rails/railtie.rb +22 -0
  16. data/lib/cloudflare/turnstile/rails/verification.rb +80 -0
  17. data/lib/cloudflare/turnstile/rails/version.rb +7 -0
  18. data/lib/cloudflare/turnstile/rails.rb +25 -0
  19. data/lib/generators/cloudflare_turnstile/install_generator.rb +15 -0
  20. data/lib/generators/cloudflare_turnstile/templates/cloudflare_turnstile.rb +18 -0
  21. data/templates/shared/app/controllers/books_controller.rb.tt +42 -0
  22. data/templates/shared/app/controllers/pages_controller.rb +3 -0
  23. data/templates/shared/app/models/book.rb.tt +19 -0
  24. data/templates/shared/app/views/books/_form.html.erb +20 -0
  25. data/templates/shared/app/views/books/create.js.erb +15 -0
  26. data/templates/shared/app/views/books/new.html.erb +5 -0
  27. data/templates/shared/app/views/books/new2.html.erb +9 -0
  28. data/templates/shared/app/views/pages/home.html.erb +4 -0
  29. data/templates/shared/cloudflare_turbolinks_ajax_cache.js +44 -0
  30. data/templates/shared/config/initializers/cloudflare_turnstile.rb +4 -0
  31. data/templates/shared/config/routes.rb +7 -0
  32. data/templates/shared/test/application_system_test_case.rb +5 -0
  33. data/templates/shared/test/controllers/books_controller_test.rb +19 -0
  34. data/templates/shared/test/system/books_test.rb +133 -0
  35. data/templates/template.rb +81 -0
  36. metadata +93 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '087645e721fc666c38e61d1ec636da847d0d4066b364fe0cfbcaed4d7a080559'
4
+ data.tar.gz: 334e358ee80c3ddc22a020b3646db47d7b9e1b91afb2319240364275822b58ce
5
+ SHA512:
6
+ metadata.gz: 64f54d13d40a27a65eaafb793a6bfb0e70cfdce39aba24f057a171a618f00899ed01a32aacc32c5ac8f9cbea58a2e44649a3e446eadfe6c966654a9611abd3c0
7
+ data.tar.gz: dcabd769cc15cd4945f5ef42bdf1bb29038eb581fd88531f3b3f5838ad7e9e25e234071e889792c8f921221a1a1ca1e69a4a0ac4bc7346d743a9e92eabf415b4
data/.rubocop.yml ADDED
@@ -0,0 +1,32 @@
1
+ plugins:
2
+ - rubocop-md
3
+ - rubocop-minitest
4
+ - rubocop-performance
5
+ - rubocop-rake
6
+
7
+ AllCops:
8
+ TargetRubyVersion: 2.6.0
9
+ NewCops: enable
10
+ Exclude:
11
+ - .git/**/*
12
+ - vendor/bundle/**/*
13
+
14
+ Layout/LineLength:
15
+ Max: 120
16
+ Exclude:
17
+ - cloudflare-turnstile-rails.gemspec
18
+
19
+ Layout/SpaceInsideHashLiteralBraces:
20
+ Enabled: false
21
+
22
+ Metrics/MethodLength:
23
+ Max: 12
24
+
25
+ Minitest/MultipleAssertions:
26
+ Enabled: false
27
+
28
+ Style/Documentation:
29
+ Enabled: false
30
+
31
+ Style/FrozenStringLiteralComment:
32
+ EnforcedStyle: never
data/Appraisals ADDED
@@ -0,0 +1,65 @@
1
+ appraise 'rails-5.0' do
2
+ gem 'rails', '~> 5.0.0'
3
+ if RUBY_VERSION >= '3.4.0'
4
+ gem 'base64'
5
+ gem 'mutex_m'
6
+ end
7
+ end
8
+
9
+ appraise 'rails-5.1' do
10
+ gem 'rails', '~> 5.1.0'
11
+ if RUBY_VERSION >= '3.4.0'
12
+ gem 'base64'
13
+ gem 'mutex_m'
14
+ end
15
+ end
16
+
17
+ appraise 'rails-5.2' do
18
+ gem 'rails', '~> 5.2.0'
19
+ if RUBY_VERSION >= '3.4.0'
20
+ gem 'base64'
21
+ gem 'mutex_m'
22
+ end
23
+ end
24
+
25
+ appraise 'rails-6.0' do
26
+ gem 'rails', '~> 6.0.0'
27
+ if RUBY_VERSION >= '3.4.0'
28
+ gem 'drb'
29
+ gem 'mutex_m'
30
+ end
31
+ end
32
+
33
+ appraise 'rails-6.1' do
34
+ gem 'rails', '~> 6.1.0'
35
+ if RUBY_VERSION >= '3.4.0'
36
+ gem 'drb'
37
+ gem 'mutex_m'
38
+ end
39
+ end
40
+
41
+ if RUBY_VERSION >= '2.7.0'
42
+ appraise 'rails-7.0' do
43
+ gem 'rails', '~> 7.0.0'
44
+ if RUBY_VERSION >= '3.4.0'
45
+ gem 'drb'
46
+ gem 'mutex_m'
47
+ end
48
+ end
49
+
50
+ appraise 'rails-7.1' do
51
+ gem 'rails', '~> 7.1.0'
52
+ end
53
+ end
54
+
55
+ if RUBY_VERSION >= '3.1.0'
56
+ appraise 'rails-7.2' do
57
+ gem 'rails', '~> 7.2.0'
58
+ end
59
+ end
60
+
61
+ if RUBY_VERSION >= '3.2.0'
62
+ appraise 'rails-8.0' do
63
+ gem 'rails', '~> 8.0.0'
64
+ end
65
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Vadim Kononov
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 ADDED
@@ -0,0 +1,249 @@
1
+ # Cloudflare Turnstile Rails
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/cloudflare-turnstile-rails.svg)](https://rubygems.org/gems/cloudflare-turnstile-rails)
4
+ [![Lint Status](https://github.com/vkononov/cloudflare-turnstile-rails/actions/workflows/lint.yml/badge.svg)](https://github.com/vkononov/cloudflare-turnstile-rails/actions/workflows/lint.yml)
5
+ [![Test Status](https://github.com/vkononov/cloudflare-turnstile-rails/actions/workflows/test.yml/badge.svg)](https://github.com/vkononov/cloudflare-turnstile-rails/actions/workflows/test.yml)
6
+
7
+ A lightweight Rails helper for effortless Cloudflare Turnstile integration with Turbo support and CSP compliance.
8
+
9
+ ## Features
10
+
11
+ * **One‑line integration**: `<%= cloudflare_turnstile_tag %>` in views, `verify_turnstile(model:)` in controllers — no extra wiring.
12
+ * **CSP nonce support**: Honors Rails’s `content_security_policy_nonce` for secure inline scripts.
13
+ * **Turbo & Turbo Streams aware**: Automatically re‑initializes widgets on `turbo:load`, `turbo:before-stream-render`, and DOM mutations.
14
+ * **Error‑code mappings**: Human‑friendly messages for Cloudflare’s test keys and common failure codes.
15
+ * **Rails Engine & Asset pipeline**: Ships a precompiled JS helper via Railtie — no manual asset setup.
16
+ * **Lightweight**: Pure Ruby/Rails with only `net/http` and `json` dependencies.
17
+
18
+ > **Note:** Even legacy Rails applications (5+) can leverage Cloudflare Turnstile by adding this gem.
19
+
20
+ ## Getting Started
21
+
22
+ ### Prerequisites
23
+
24
+ Before you begin, you should have your own Cloudflare Turnstile keys:
25
+
26
+ - **Site Key** and **Secret Key** from Cloudflare.
27
+
28
+ > Cloudflare provides extensive documentation for Turnstile [here](https://developers.cloudflare.com/turnstile/). It is highly recommended to read it to understand its options and idiosyncrasies.
29
+
30
+ ### Installation
31
+
32
+ Add the gem to your Gemfile and bundle:
33
+
34
+ ```ruby
35
+ gem 'cloudflare-turnstile-rails'
36
+ ```
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ Generate the default initializer:
43
+
44
+ ```bash
45
+ bin/rails generate cloudflare_turnstile:install
46
+ ```
47
+
48
+ Configure your **Site Key** and **Secret Key** in `config/initializers/cloudflare_turnstile.rb`:
49
+
50
+ ```ruby
51
+ Cloudflare::Turnstile::Rails.configure do |config|
52
+ # Set your Cloudflare Turnstile Site Key and Secret Key.
53
+ config.site_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SITE_KEY', nil)
54
+ config.secret_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SECRET_KEY', nil)
55
+
56
+ # Optional: Append render or onload query params
57
+ # config.render = 'explicit'
58
+ # config.onload = 'onloadMyCallback'
59
+ end
60
+ ```
61
+
62
+ ### Frontend Integration
63
+
64
+ > The helper injects the Turnstile widget and accompanying JavaScript inline by default (honoring Rails' `content_security_policy_nonce`), so there's no need to allow `unsafe-inline` in your CSP.
65
+
66
+ Include the widget in your views or forms:
67
+
68
+ ```erb
69
+ <%= cloudflare_turnstile_tag %>
70
+ ```
71
+
72
+ However, it is recommended to match your `theme` and `language` to your app’s design and locale. You can do this by passing `data` attributes to the helper:
73
+
74
+ ```erb
75
+ <%= cloudflare_turnstile_tag data: { theme: 'auto', language: 'en' } %>
76
+ ```
77
+
78
+ * For all available **data-**\* options (e.g., `action`, `cdata`, `theme`, etc.), refer to the official Cloudflare client-side rendering docs:
79
+ [https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations)
80
+ * **Supported locales** for the widget UI can be found here:
81
+ [https://developers.cloudflare.com/turnstile/reference/supported-languages/](https://developers.cloudflare.com/turnstile/reference/supported-languages/)
82
+
83
+ ### Backend Validation
84
+
85
+ In your controller, call:
86
+
87
+ ```ruby
88
+ if verify_turnstile(model: @user)
89
+ # success → returns a VerificationResponse object
90
+ else
91
+ # failure → returns false and adds errors to `@user`
92
+ render :new, status: :unprocessable_entity
93
+ end
94
+ ```
95
+
96
+ * In addition to the `model` option, you can pass any **siteverify** parameters (e.g., `secret`, `remoteip`, `idempotency_key`) supported by Cloudflare’s server-side validation API:
97
+ [https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters)
98
+
99
+ * On success, `verify_turnstile` returns a `VerificationResponse` (with methods like `.success?`, `.errors`, `.action`, `.cdata`), so you can inspect frontend-set values (`data-action`, `data-cdata`, etc.). On failure it returns `false` and adds a validation error to your model (if provided).
100
+
101
+ ### Turbo & Turbo Streams Support
102
+
103
+ All widgets will re‑initialize automatically on full and soft navigations (`turbo:load`), on `<turbo-stream>` renders (`turbo:before-stream-render`), and on DOM mutations — no extra wiring needed.
104
+
105
+ ### Turbolinks Support
106
+
107
+ If your Rails app still uses Turbolinks (rather than Turbo), you can add a small helper to your JavaScript pack so that remote form submissions returning HTML correctly display validation errors without a full page reload. Simply copy the file:
108
+
109
+ ```
110
+ templates / shared / cloudflare_turbolinks_ajax_cache.js
111
+ ```
112
+
113
+ into your application’s JavaScript entrypoint (for example `app/javascript/packs/application.js`). This script listens for Rails UJS `ajax:complete` events that return HTML, caches the response as a Turbolinks snapshot, and then restores it via `Turbolinks.visit`, ensuring forms with validation errors are re‑rendered seamlessly.
114
+
115
+ ## Testing Your Integration
116
+
117
+ Cloudflare provides dummy sitekeys and secret keys for development and testing. You can use these to simulate every possible outcome of a Turnstile challenge without touching your production configuration. For future updates, see [https://developers.cloudflare.com/turnstile/troubleshooting/testing/](https://developers.cloudflare.com/turnstile/troubleshooting/testing/).
118
+
119
+ ### Dummy Sitekeys
120
+
121
+ | Sitekey | Description | Visibility |
122
+ |----------------------------|---------------------------------|------------|
123
+ | `1x00000000000000000000AA` | Always passes | visible |
124
+ | `2x00000000000000000000AB` | Always blocks | visible |
125
+ | `1x00000000000000000000BB` | Always passes | invisible |
126
+ | `2x00000000000000000000BB` | Always blocks | invisible |
127
+ | `3x00000000000000000000FF` | Forces an interactive challenge | visible |
128
+
129
+ ### Dummy Secret Keys
130
+
131
+ | Secret key | Description |
132
+ |---------------------------------------|--------------------------------------|
133
+ | `1x0000000000000000000000000000000AA` | Always passes |
134
+ | `2x0000000000000000000000000000000AA` | Always fails |
135
+ | `3x0000000000000000000000000000000AA` | Yields a "token already spent" error |
136
+
137
+ Use these dummy values in your **development** environment to verify all flows. Ensure you match a dummy secret key with its corresponding sitekey when calling `verify_turnstile`. Development tokens will look like `XXXX.DUMMY.TOKEN.XXXX`.
138
+
139
+ ## Upgrade Guide
140
+
141
+ This gem is fully compatible with Rails **5.0 and above**, and no special upgrade steps are required:
142
+
143
+ * **Simply update Rails** in your application as usual.
144
+ * Continue using the same `cloudflare_turnstile_tag` helper in your views and `verify_turnstile` in your controllers.
145
+ * All Turbo, Turbo Streams, and Turbolinks integrations continue to work without changes.
146
+
147
+ If you run into any issues after upgrading Rails, please [open an issue](https://github.com/vkononov/cloudflare-turnstile-rails/issues) so we can address it promptly.
148
+
149
+ ## Troubleshooting
150
+
151
+ **Duplicate Widgets**
152
+ - If more than one Turnstile widget appears in the same container, this indicates a bug in the gem—please [open an issue](https://github.com/vkononov/cloudflare-turnstile-rails/issues) so it can be addressed.
153
+
154
+ **Explicit Rendering**
155
+ - If you’ve configured explicit mode (`config.render = 'explicit'` or `cloudflare_turnstile_tag render: 'explicit'`) but widgets still auto-render, override the default container class:
156
+
157
+ ```erb
158
+ <%= cloudflare_turnstile_tag class: nil %>
159
+ ```
160
+
161
+ or
162
+
163
+ ```erb
164
+ <%= cloudflare_turnstile_tag class: 'my-widget-class' %>
165
+ ```
166
+
167
+ - By default Turnstile targets elements with the `cf-turnstile` class. For more details, see Cloudflare’s [https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget).
168
+
169
+ **CSP Nonce Issues**
170
+ - When using Rails’ CSP nonces, make sure `content_security_policy_nonce` is available in your view context — otherwise the Turnstile script may be blocked.
171
+
172
+ **Server Validation Errors**
173
+ - Validation failures (invalid, expired, or already‑used tokens) surface as model errors. Consult Cloudflare’s [server-side troubleshooting](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) for common error codes and test keys.
174
+
175
+ > Still stuck? Check the Cloudflare Turnstile docs: [https://developers.cloudflare.com/turnstile/get-started/](https://developers.cloudflare.com/turnstile/get-started/)
176
+
177
+ ## Development
178
+
179
+ ### Setup
180
+
181
+ Install dependencies, linters, and prepare everything in one step:
182
+
183
+ ```bash
184
+ bin/setup
185
+ ```
186
+
187
+ ### Running the Test Suite
188
+
189
+ [Appraisal](https://github.com/thoughtbot/appraisal) is used to run the full test suite against multiple Rails versions by generating separate Gemfiles and isolating each environment. To install dependencies and exercise all unit, integration and system tests:
190
+
191
+ Execute **all** tests (unit, integration, system) across every Ruby & Rails combination:
192
+
193
+ ```bash
194
+ bundle exec appraisal install
195
+ bundle exec appraisal rake test
196
+ ```
197
+
198
+ > **CI Note:** Our GitHub Actions [.github/workflows/test.yml](https://github.com/vkononov/cloudflare-turnstile-rails/blob/main/.github/workflows/test.yml) runs this command on each Ruby/Rails combo and captures screenshots from system specs.
199
+
200
+ ### Code Linting
201
+
202
+ Enforce code style with RuboCop (latest Ruby only)::
203
+
204
+ ```bash
205
+ bundle exec rubocop
206
+ ```
207
+
208
+ > **CI Note:** We run this via [.github/workflows/lint.yml](https://github.com/vkononov/cloudflare-turnstile-rails/blob/main/.github/workflows/lint.yml) on the latest Ruby only.
209
+
210
+ ### Generating Rails Apps Locally
211
+
212
+ To replicate the integration examples on your machine, you can generate a Rails app directly from the template:
213
+
214
+ ```bash
215
+ rails new test_app \
216
+ --skip-git --skip-action-mailer --skip-active-record \
217
+ --skip-action-cable --skip-sprockets --skip-javascript \
218
+ -m templates/template.rb
219
+ ```
220
+
221
+ Get the exact command from the `test/integration/` folder, where each integration test has a corresponding Rails app template. For example, to replicate the `test/integration/rails7.rb` test for Rails `v7.0.4`, run:
222
+
223
+ ```bash
224
+ gem install rails -v 7_0_4
225
+
226
+ rails _7_0_4_ new test_app \
227
+ --skip-git --skip-keeps \
228
+ --skip-action-mailer --skip-action-mailbox --skip-action-text \
229
+ --skip-active-record --skip-active-job --skip-active-storage \
230
+ --skip-action-cable --skip-jbuilder --skip-bootsnap --skip-api \
231
+ -m test/integration/rails_7_0_4.rb
232
+ ```
233
+
234
+ Then:
235
+
236
+ ```bash
237
+ cd test_app
238
+ bin/rails server
239
+ ```
240
+
241
+ This bootstraps an app preconfigured for Cloudflare Turnstile matching the versions under `test/integration/`.
242
+
243
+ ## Contributing
244
+
245
+ Bug reports and pull requests are welcome on GitHub at https://github.com/vkononov/cloudflare-turnstile-rails.
246
+
247
+ ## License
248
+
249
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'minitest/test_task'
3
+
4
+ Minitest::TestTask.create
5
+
6
+ require 'rubocop/rake_task'
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[test rubocop]
@@ -0,0 +1,40 @@
1
+ (function(){
2
+ function reinitializeTurnstile() {
3
+ if (typeof turnstile !== "undefined") {
4
+ document.querySelectorAll('.cf-turnstile').forEach(function(el) {
5
+ if (!el.dataset.initialized && el.childElementCount === 0) {
6
+ turnstile.render(el);
7
+ el.dataset.initialized = true;
8
+ }
9
+ });
10
+ }
11
+ }
12
+
13
+ if (!window._turnstileHelperLoaded) {
14
+ window._turnstileHelperLoaded = true;
15
+
16
+ // read our data-attribute to know which CF script URL to use:
17
+ var me = document.currentScript;
18
+ var cfUrl = me.getAttribute('data-script-url');
19
+ var helper = document.createElement('script');
20
+ helper.src = cfUrl;
21
+ helper.async = true;
22
+ helper.defer = true;
23
+ var nonce = me.getAttribute('nonce');
24
+ if (nonce) helper.nonce = nonce;
25
+ document.head.appendChild(helper);
26
+
27
+ // set up Turbo hooks only once
28
+ document.addEventListener("turbo:load", reinitializeTurnstile);
29
+ document.addEventListener("turbo:before-stream-render", function(event) {
30
+ var orig = event.detail.render.bind(event.detail);
31
+ event.detail.render = function() {
32
+ orig.apply(this, arguments);
33
+ reinitializeTurnstile();
34
+ };
35
+ });
36
+ }
37
+
38
+ // always try to render any containers already in the DOM
39
+ reinitializeTurnstile();
40
+ })();
@@ -0,0 +1,41 @@
1
+ module Cloudflare
2
+ module Turnstile
3
+ module Rails
4
+ class Configuration
5
+ attr_writer :script_url
6
+ attr_accessor :site_key, :secret_key, :render, :onload
7
+
8
+ def initialize
9
+ @script_url = Cloudflare::SCRIPT_URL
10
+ @site_key = nil
11
+ @secret_key = nil
12
+ @render = nil
13
+ @onload = nil
14
+ end
15
+
16
+ # Dynamically build the URL every time, so that
17
+ # config.render and config.onload applied after init take effect.
18
+ def script_url
19
+ return @script_url unless @script_url == Cloudflare::SCRIPT_URL
20
+
21
+ # Otherwise, append render/onload if present:
22
+ params = []
23
+ params << "render=#{CGI.escape(@render)}" unless @render.nil?
24
+ params << "onload=#{CGI.escape(@onload)}" unless @onload.nil?
25
+
26
+ params.empty? ? Cloudflare::SCRIPT_URL : "#{Cloudflare::SCRIPT_URL}?#{params.join('&')}"
27
+ end
28
+
29
+ def validate!
30
+ if site_key.nil? || site_key.strip.empty?
31
+ raise ConfigurationError, 'Cloudflare Turnstile site_key is not set.'
32
+ end
33
+
34
+ return unless secret_key.nil? || secret_key.strip.empty?
35
+
36
+ raise ConfigurationError, 'Cloudflare Turnstile secret_key is not set.'
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ module Cloudflare
2
+ module Turnstile
3
+ module Rails
4
+ module Cloudflare
5
+ # Client-side script for rendering Turnstile widgets
6
+ SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'.freeze
7
+
8
+ # Server-side endpoint for verifying Turnstile tokens
9
+ SITE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'.freeze
10
+
11
+ # Default hidden input field name for Turnstile token submission
12
+ RESPONSE_FIELD_NAME = 'cf-turnstile-response'.freeze
13
+
14
+ # Default CSS class applied to Turnstile widget containers
15
+ WIDGET_CLASS = 'cf-turnstile'.freeze
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ module Cloudflare
2
+ module Turnstile
3
+ module Rails
4
+ module ErrorCodes
5
+ # https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
6
+
7
+ MISSING_INPUT_SECRET = 'missing-input-secret'.freeze
8
+ INVALID_INPUT_SECRET = 'invalid-input-secret'.freeze
9
+ MISSING_INPUT_RESPONSE = 'missing-input-response'.freeze
10
+ INVALID_INPUT_RESPONSE = 'invalid-input-response'.freeze
11
+ BAD_REQUEST = 'bad-request'.freeze
12
+ TIMEOUT_OR_DUPLICATE = 'timeout-or-duplicate'.freeze
13
+ INTERNAL_ERROR = 'internal-error'.freeze
14
+
15
+ ALL = [
16
+ MISSING_INPUT_SECRET,
17
+ INVALID_INPUT_SECRET,
18
+ MISSING_INPUT_RESPONSE,
19
+ INVALID_INPUT_RESPONSE,
20
+ BAD_REQUEST,
21
+ TIMEOUT_OR_DUPLICATE,
22
+ INTERNAL_ERROR
23
+ ].freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ require_relative 'error_codes'
2
+
3
+ module Cloudflare
4
+ module Turnstile
5
+ module Rails
6
+ module ErrorMessages
7
+ MAP = {
8
+ ErrorCodes::TIMEOUT_OR_DUPLICATE => 'Turnstile token has already been used or expired.',
9
+ ErrorCodes::INVALID_INPUT_RESPONSE => 'Turnstile token is invalid.',
10
+ ErrorCodes::MISSING_INPUT_RESPONSE => 'Turnstile response was missing.',
11
+ ErrorCodes::BAD_REQUEST => 'Bad request to Turnstile verification API.',
12
+ ErrorCodes::INTERNAL_ERROR => 'Internal error at Turnstile. Please try again.',
13
+ ErrorCodes::MISSING_INPUT_SECRET => 'Server misconfiguration: Turnstile secret key missing.',
14
+ ErrorCodes::INVALID_INPUT_SECRET => 'Server misconfiguration: Turnstile secret key invalid.'
15
+ }.freeze
16
+
17
+ DEFAULT_MESSAGE = "We could verify that you're human. Please try again.".freeze
18
+ MISSING_TOKEN_MESSAGE = 'Cloudflare Turnstile verification missing.'.freeze
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ require_relative 'constants/cloudflare'
2
+ require_relative 'constants/error_messages'
3
+ require_relative 'verification'
4
+
5
+ module Cloudflare
6
+ module Turnstile
7
+ module Rails
8
+ module ControllerMethods
9
+ def verify_turnstile(model: nil, secret: nil, response: nil, remoteip: nil, idempotency_key: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
10
+ response ||= params[Cloudflare::RESPONSE_FIELD_NAME]
11
+
12
+ if response.nil? || response.strip.empty?
13
+ error_message = ErrorMessages::MISSING_TOKEN_MESSAGE
14
+ model&.errors&.add(:base, error_message)
15
+ return false
16
+ end
17
+
18
+ result = Rails::Verification.verify(
19
+ secret: secret,
20
+ response: response,
21
+ remoteip: remoteip,
22
+ idempotency_key: idempotency_key
23
+ )
24
+
25
+ unless result.success?
26
+ error_code = result.errors.first
27
+ error_message = ErrorMessages::MAP.fetch(error_code, ErrorMessages::DEFAULT_MESSAGE)
28
+ model&.errors&.add(:base, error_message)
29
+ return false
30
+ end
31
+
32
+ result
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ module Cloudflare
2
+ module Turnstile
3
+ module Rails
4
+ class Engine < ::Rails::Engine
5
+ engine_name 'cloudflare_turnstile_rails'
6
+
7
+ initializer 'cloudflare_turnstile.assets' do |app|
8
+ js_path = ::Cloudflare::Turnstile::Rails::Engine.root.join(
9
+ 'lib', 'cloudflare', 'turnstile', 'rails', 'assets', 'javascripts'
10
+ )
11
+ app.config.assets.paths << js_path
12
+ app.config.assets.precompile += %w[cloudflare_turnstile_helper.js]
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,35 @@
1
+ require_relative 'constants/cloudflare'
2
+
3
+ module Cloudflare
4
+ module Turnstile
5
+ module Rails
6
+ module Helpers
7
+ def cloudflare_turnstile_tag(site_key: nil, include_script: true, **html_options) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
8
+ site_key ||= Rails.configuration.site_key
9
+ Rails.configuration.validate!
10
+
11
+ html_options[:class] = Cloudflare::WIDGET_CLASS unless html_options.key?(:class)
12
+ html_options[:data] ||= {}
13
+ html_options[:data][:sitekey] ||= site_key
14
+
15
+ script_tag = nil
16
+ if include_script && !@_ct_helper_rendered
17
+ @_ct_helper_rendered = true
18
+
19
+ # Emit exactly one tag:
20
+ script_tag = javascript_include_tag(
21
+ 'cloudflare_turnstile_helper',
22
+ async: true,
23
+ defer: true,
24
+ nonce: (defined?(content_security_policy_nonce) ? content_security_policy_nonce : nil),
25
+ data: { 'script-url': Rails.configuration.script_url }
26
+ )
27
+ end
28
+
29
+ widget = content_tag(:div, '', html_options)
30
+ safe_join([script_tag, widget].compact, "\n")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ require_relative 'controller_methods'
2
+ require_relative 'helpers'
3
+
4
+ module Cloudflare
5
+ module Turnstile
6
+ module Rails
7
+ class Railtie < ::Rails::Railtie
8
+ initializer 'cloudflare.turnstile.rails.controller_methods' do
9
+ ActiveSupport.on_load(:action_controller) do
10
+ include Rails::ControllerMethods
11
+ end
12
+ end
13
+
14
+ initializer 'cloudflare.turnstile.rails.helpers' do
15
+ ActiveSupport.on_load(:action_view) do
16
+ include Rails::Helpers
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end