cloudflare-turnstile-rails 0.8.0 → 0.9.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +7 -1
  3. data/README.md +54 -7
  4. data/lib/cloudflare/turnstile/rails/configuration.rb +2 -11
  5. data/lib/cloudflare/turnstile/rails/constants/{error_codes.rb → error_code.rb} +1 -1
  6. data/lib/cloudflare/turnstile/rails/constants/error_message.rb +26 -0
  7. data/lib/cloudflare/turnstile/rails/controller_methods.rb +12 -20
  8. data/lib/cloudflare/turnstile/rails/helpers.rb +0 -2
  9. data/lib/cloudflare/turnstile/rails/verification.rb +21 -18
  10. data/lib/cloudflare/turnstile/rails/version.rb +1 -1
  11. data/lib/generators/cloudflare_turnstile/templates/cloudflare_turnstile.rb +5 -0
  12. metadata +5 -20
  13. data/lib/cloudflare/turnstile/rails/constants/error_messages.rb +0 -22
  14. data/templates/shared/app/controllers/books_controller.rb.tt +0 -42
  15. data/templates/shared/app/controllers/pages_controller.rb +0 -3
  16. data/templates/shared/app/models/book.rb.tt +0 -19
  17. data/templates/shared/app/views/books/_form.html.erb +0 -20
  18. data/templates/shared/app/views/books/create.js.erb +0 -15
  19. data/templates/shared/app/views/books/new.html.erb +0 -5
  20. data/templates/shared/app/views/books/new2.html.erb +0 -9
  21. data/templates/shared/app/views/pages/home.html.erb +0 -4
  22. data/templates/shared/cloudflare_turbolinks_ajax_cache.js +0 -44
  23. data/templates/shared/config/initializers/cloudflare_turnstile.rb +0 -4
  24. data/templates/shared/config/routes.rb +0 -7
  25. data/templates/shared/test/application_system_test_case.rb +0 -5
  26. data/templates/shared/test/controllers/books_controller_test.rb +0 -19
  27. data/templates/shared/test/system/books_test.rb +0 -133
  28. data/templates/template.rb +0 -81
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '087645e721fc666c38e61d1ec636da847d0d4066b364fe0cfbcaed4d7a080559'
4
- data.tar.gz: 334e358ee80c3ddc22a020b3646db47d7b9e1b91afb2319240364275822b58ce
3
+ metadata.gz: ab251a17ed52bd0e141cbf139543e49b00fc786ddfec79e9ef8c47f8e52cd25b
4
+ data.tar.gz: 22fdeb19187f6a44b8f1ff0da9b40011e5cdd8f6cb51956821cbcc720bbe7a0f
5
5
  SHA512:
6
- metadata.gz: 64f54d13d40a27a65eaafb793a6bfb0e70cfdce39aba24f057a171a618f00899ed01a32aacc32c5ac8f9cbea58a2e44649a3e446eadfe6c966654a9611abd3c0
7
- data.tar.gz: dcabd769cc15cd4945f5ef42bdf1bb29038eb581fd88531f3b3f5838ad7e9e25e234071e889792c8f921221a1a1ca1e69a4a0ac4bc7346d743a9e92eabf415b4
6
+ metadata.gz: 58745614a8eb83139d697248a31e258bf33af84cdd8e306f975fa7e6c6f21c11b42c19850cbdd1906d9d3dc85dbbf47bbda3479560501fbd139c024a80e93398
7
+ data.tar.gz: ceb98520676e173b2a47dfddc3a97f7eba12495fb641555e8771e7aac81928c988ab99062eaf4e1fd8ed61ddfcb1d82c258f69a02e9fe1d0e285ece82898c89e
data/.rubocop.yml CHANGED
@@ -14,6 +14,7 @@ AllCops:
14
14
  Layout/LineLength:
15
15
  Max: 120
16
16
  Exclude:
17
+ - lib/cloudflare/turnstile/rails/constants/*.rb
17
18
  - cloudflare-turnstile-rails.gemspec
18
19
 
19
20
  Layout/SpaceInsideHashLiteralBraces:
@@ -28,5 +29,10 @@ Minitest/MultipleAssertions:
28
29
  Style/Documentation:
29
30
  Enabled: false
30
31
 
32
+ Style/EmptyElse:
33
+ Exclude:
34
+ - README.md
35
+
31
36
  Style/FrozenStringLiteralComment:
32
- EnforcedStyle: never
37
+ EnforcedStyle: never
38
+
data/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  A lightweight Rails helper for effortless Cloudflare Turnstile integration with Turbo support and CSP compliance.
8
8
 
9
+ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://www.buymeacoffee.com/vkononov)
10
+
9
11
  ## Features
10
12
 
11
13
  * **One‑line integration**: `<%= cloudflare_turnstile_tag %>` in views, `verify_turnstile(model:)` in controllers — no extra wiring.
@@ -82,21 +84,66 @@ However, it is recommended to match your `theme` and `language` to your app’s
82
84
 
83
85
  ### Backend Validation
84
86
 
85
- In your controller, call:
87
+ To validate a Turnstile response in your controller, use either `valid_turnstile?` or `turnstile_valid?`. Both methods behave identically and return a boolean. The `model` parameter is optional:
86
88
 
87
89
  ```ruby
88
- if verify_turnstile(model: @user)
89
- # success returns a VerificationResponse object
90
+ if valid_turnstile?(model: @user)
91
+ # Passed: returns true
90
92
  else
91
- # failure returns false and adds errors to `@user`
93
+ # Failed: returns false, adds errors to @user
92
94
  render :new, status: :unprocessable_entity
93
95
  end
94
96
  ```
95
97
 
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
+ You may also pass additional **siteverify** parameters (e.g., `secret`, `response`, `remoteip`, `idempotency_key`) supported by Cloudflare’s API:
99
+ [Cloudflare Server-Side Validation Parameters](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#accepted-parameters)
100
+
101
+ #### Accessing Full Validation Details
102
+
103
+ To inspect the entire verification payload, use `verify_turnstile`. It returns a `VerificationResponse` object with detailed information:
104
+
105
+ ```ruby
106
+ result = verify_turnstile(model: @user)
107
+ ```
108
+
109
+ This method still adds errors to the model if verification fails. You can query the response:
98
110
 
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).
111
+ ```ruby
112
+ if result.success?
113
+ # Passed
114
+ else
115
+ # Failed — inspect result.errors or result.raw
116
+ end
117
+ ```
118
+
119
+ #### Example Responses
120
+
121
+ ```ruby
122
+ # Success:
123
+ Cloudflare::Turnstile::Rails::VerificationResponse @raw = {
124
+ 'success' => true,
125
+ 'error-codes' => [],
126
+ 'challenge_ts' => '2025-05-19T02:52:31.179Z',
127
+ 'hostname' => 'example.com',
128
+ 'metadata' => { 'result_with_testing_key' => true }
129
+ }
130
+
131
+ # Failure:
132
+ Cloudflare::Turnstile::Rails::VerificationResponse @raw = {
133
+ 'success' => false,
134
+ 'error-codes' => ['invalid-input-response'],
135
+ 'messages' => [],
136
+ 'metadata' => { 'result_with_testing_key' => true }
137
+ }
138
+ ```
139
+
140
+ #### Response Methods
141
+
142
+ The following instance methods are available on `VerificationResponse`:
143
+
144
+ ```plaintext
145
+ action, cdata, challenge_ts, errors, hostname, metadata, raw, success?, to_h
146
+ ```
100
147
 
101
148
  ### Turbo & Turbo Streams Support
102
149
 
@@ -3,7 +3,7 @@ module Cloudflare
3
3
  module Rails
4
4
  class Configuration
5
5
  attr_writer :script_url
6
- attr_accessor :site_key, :secret_key, :render, :onload
6
+ attr_accessor :site_key, :secret_key, :render, :onload, :auto_populate_response_in_test_env
7
7
 
8
8
  def initialize
9
9
  @script_url = Cloudflare::SCRIPT_URL
@@ -11,6 +11,7 @@ module Cloudflare
11
11
  @secret_key = nil
12
12
  @render = nil
13
13
  @onload = nil
14
+ @auto_populate_response_in_test_env = true
14
15
  end
15
16
 
16
17
  # Dynamically build the URL every time, so that
@@ -25,16 +26,6 @@ module Cloudflare
25
26
 
26
27
  params.empty? ? Cloudflare::SCRIPT_URL : "#{Cloudflare::SCRIPT_URL}?#{params.join('&')}"
27
28
  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
29
  end
39
30
  end
40
31
  end
@@ -1,7 +1,7 @@
1
1
  module Cloudflare
2
2
  module Turnstile
3
3
  module Rails
4
- module ErrorCodes
4
+ module ErrorCode
5
5
  # https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes
6
6
 
7
7
  MISSING_INPUT_SECRET = 'missing-input-secret'.freeze
@@ -0,0 +1,26 @@
1
+ require_relative 'error_code'
2
+
3
+ module Cloudflare
4
+ module Turnstile
5
+ module Rails
6
+ module ErrorMessage
7
+ MAP = {
8
+ ErrorCode::TIMEOUT_OR_DUPLICATE => 'Turnstile token has already been used or expired.'.freeze,
9
+ ErrorCode::INVALID_INPUT_RESPONSE => 'Turnstile response parameter is invalid.'.freeze,
10
+ ErrorCode::MISSING_INPUT_RESPONSE => 'Turnstile response parameter was not passed.'.freeze,
11
+ ErrorCode::BAD_REQUEST => 'Turnstile request was rejected because it was malformed.'.freeze,
12
+ ErrorCode::INTERNAL_ERROR => 'Turnstile Internal error occurred while validating the response. Please try again.'.freeze,
13
+ ErrorCode::MISSING_INPUT_SECRET => 'Turnstile secret key missing.'.freeze,
14
+ ErrorCode::INVALID_INPUT_SECRET => 'Turnstile secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.'.freeze
15
+ }.freeze
16
+
17
+ DEFAULT = "We could not verify that you're human. Please try again.".freeze
18
+
19
+ def self.for(code)
20
+ base = MAP.fetch(code, DEFAULT)
21
+ "#{base} (#{code})"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,36 +1,28 @@
1
- require_relative 'constants/cloudflare'
2
- require_relative 'constants/error_messages'
1
+ require_relative 'constants/error_message'
3
2
  require_relative 'verification'
4
3
 
5
4
  module Cloudflare
6
5
  module Turnstile
7
6
  module Rails
8
7
  module ControllerMethods
9
- def verify_turnstile(model: nil, secret: nil, response: nil, remoteip: nil, idempotency_key: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
8
+ def verify_turnstile(model: nil, **opts)
10
9
  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
- )
10
+ result = Rails::Verification.verify(response: response, **opts)
24
11
 
25
12
  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
13
+ message = ErrorMessage::DEFAULT
14
+ model&.errors&.add(:base, message)
30
15
  end
31
16
 
32
17
  result
33
18
  end
19
+
20
+ def valid_turnstile?(model: nil, **opts)
21
+ response = verify_turnstile(model: model, **opts)
22
+ response.is_a?(VerificationResponse) && response.success?
23
+ end
24
+
25
+ alias turnstile_valid? valid_turnstile?
34
26
  end
35
27
  end
36
28
  end
@@ -6,8 +6,6 @@ module Cloudflare
6
6
  module Helpers
7
7
  def cloudflare_turnstile_tag(site_key: nil, include_script: true, **html_options) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
8
8
  site_key ||= Rails.configuration.site_key
9
- Rails.configuration.validate!
10
-
11
9
  html_options[:class] = Cloudflare::WIDGET_CLASS unless html_options.key?(:class)
12
10
  html_options[:data] ||= {}
13
11
  html_options[:data][:sitekey] ||= site_key
@@ -1,3 +1,5 @@
1
+ require 'net/http'
2
+
1
3
  require_relative 'constants/cloudflare'
2
4
 
3
5
  module Cloudflare
@@ -44,35 +46,36 @@ module Cloudflare
44
46
  end
45
47
 
46
48
  module Verification
47
- def self.verify(response:, secret: nil, remoteip: nil, idempotency_key: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
48
- raise ConfigurationError, 'Turnstile response token is missing' if response.nil? || response.strip.empty?
49
+ def self.verify(response: nil, secret: nil, remoteip: nil, idempotency_key: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
50
+ if response.nil? || response.strip.empty?
51
+ unless ::Rails.env.test? && Rails.configuration.auto_populate_response_in_test_env
52
+ raise ConfigurationError, ErrorMessage.for(ErrorCode::MISSING_INPUT_RESPONSE)
53
+ end
49
54
 
50
- config = Rails.configuration
51
- secret ||= config.secret_key
55
+ response = 'dummy-response'
56
+ end
52
57
 
53
- raise ConfigurationError, 'Cloudflare Turnstile secret_key is not set.' if secret.nil? || secret.strip.empty?
58
+ secret ||= Rails.configuration.secret_key
59
+ if secret.nil? || secret.strip.empty?
60
+ raise ConfigurationError, ErrorMessage.for(ErrorCode::MISSING_INPUT_SECRET)
61
+ end
54
62
 
55
- body = {
56
- 'secret' => secret,
57
- 'response' => response
58
- }
63
+ body = { 'secret' => secret, 'response' => response }
59
64
  body['remoteip'] = remoteip if remoteip
60
65
  body['idempotency_key'] = idempotency_key if idempotency_key
61
66
 
62
67
  uri = URI.parse(Cloudflare::SITE_VERIFY_URL)
63
- headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
64
- http = Net::HTTP.new(uri.host, uri.port)
65
- http.use_ssl = true
66
-
67
- request = Net::HTTP::Post.new(uri.request_uri, headers)
68
+ request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type': 'application/x-www-form-urlencoded')
68
69
  request.set_form_data(body)
69
70
 
70
- response = http.request(request)
71
- json = JSON.parse(response.body)
71
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
72
+ begin
73
+ json = JSON.parse(res.body)
74
+ rescue JSON::ParserError
75
+ raise ConfigurationError, ErrorMessage.for(ErrorCode::INTERNAL_ERROR)
76
+ end
72
77
 
73
78
  VerificationResponse.new(json)
74
- rescue JSON::ParserError
75
- raise ConfigurationError, 'Unable to parse Cloudflare Turnstile verification response'
76
79
  end
77
80
  end
78
81
  end
@@ -1,7 +1,7 @@
1
1
  module Cloudflare
2
2
  module Turnstile
3
3
  module Rails
4
- VERSION = '0.8.0'.freeze
4
+ VERSION = '0.9.0'.freeze
5
5
  end
6
6
  end
7
7
  end
@@ -15,4 +15,9 @@ Cloudflare::Turnstile::Rails.configure do |config|
15
15
  # If `script_url` is provided, it will be used directly and render/onload options will be ignored.
16
16
  # config.render = 'explicit'
17
17
  # config.onload = 'onloadTurnstileCallback'
18
+
19
+ # In the Rails Test environment, automatically fill in a dummy response if none was provided.
20
+ # This lets you keep existing controller tests without having to add
21
+ # params["cf-turnstile-response"] manually in every test.
22
+ # config.auto_populate_response_in_test_env = true
18
23
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cloudflare-turnstile-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vadim Kononov
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -40,8 +40,8 @@ files:
40
40
  - lib/cloudflare/turnstile/rails/assets/javascripts/cloudflare_turnstile_helper.js
41
41
  - lib/cloudflare/turnstile/rails/configuration.rb
42
42
  - lib/cloudflare/turnstile/rails/constants/cloudflare.rb
43
- - lib/cloudflare/turnstile/rails/constants/error_codes.rb
44
- - lib/cloudflare/turnstile/rails/constants/error_messages.rb
43
+ - lib/cloudflare/turnstile/rails/constants/error_code.rb
44
+ - lib/cloudflare/turnstile/rails/constants/error_message.rb
45
45
  - lib/cloudflare/turnstile/rails/controller_methods.rb
46
46
  - lib/cloudflare/turnstile/rails/engine.rb
47
47
  - lib/cloudflare/turnstile/rails/helpers.rb
@@ -50,21 +50,6 @@ files:
50
50
  - lib/cloudflare/turnstile/rails/version.rb
51
51
  - lib/generators/cloudflare_turnstile/install_generator.rb
52
52
  - lib/generators/cloudflare_turnstile/templates/cloudflare_turnstile.rb
53
- - templates/shared/app/controllers/books_controller.rb.tt
54
- - templates/shared/app/controllers/pages_controller.rb
55
- - templates/shared/app/models/book.rb.tt
56
- - templates/shared/app/views/books/_form.html.erb
57
- - templates/shared/app/views/books/create.js.erb
58
- - templates/shared/app/views/books/new.html.erb
59
- - templates/shared/app/views/books/new2.html.erb
60
- - templates/shared/app/views/pages/home.html.erb
61
- - templates/shared/cloudflare_turbolinks_ajax_cache.js
62
- - templates/shared/config/initializers/cloudflare_turnstile.rb
63
- - templates/shared/config/routes.rb
64
- - templates/shared/test/application_system_test_case.rb
65
- - templates/shared/test/controllers/books_controller_test.rb
66
- - templates/shared/test/system/books_test.rb
67
- - templates/template.rb
68
53
  homepage: https://github.com/vkononov/cloudflare-turnstile-rails
69
54
  licenses:
70
55
  - MIT
@@ -87,7 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
87
72
  - !ruby/object:Gem::Version
88
73
  version: '0'
89
74
  requirements: []
90
- rubygems_version: 3.6.2
75
+ rubygems_version: 3.6.7
91
76
  specification_version: 4
92
77
  summary: Simple Cloudflare Turnstile integration for Ruby on Rails.
93
78
  test_files: []
@@ -1,22 +0,0 @@
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
@@ -1,42 +0,0 @@
1
- class BooksController < ApplicationController
2
- def new
3
- @book = Book.new
4
- end
5
-
6
- def new2
7
- @book1 = Book.new
8
- @book2 = Book.new
9
- end
10
-
11
- def create
12
- @book = Book.new(book_params)
13
-
14
- respond_to do |format|
15
- if verify_turnstile(model: @book) && @book.valid?
16
- format.html { redirect_to new_book_url, notice: "Book was successfully created." }
17
- else
18
- format.html { render :new, status: :unprocessable_entity }
19
- format.js
20
- <% if Gem::Version.new(Rails.version) >= Gem::Version.new("7.0.0") -%>
21
- format.turbo_stream do
22
- render turbo_stream: turbo_stream.replace(
23
- params[:turbo_form_id],
24
- partial: "books/form",
25
- locals: { book: @book, turbo_form_id: params[:turbo_form_id] }
26
- )
27
- end
28
- <% end -%>
29
- end
30
- end
31
- end
32
-
33
- private
34
-
35
- def book_params
36
- <% if Gem::Version.new(Rails.version) >= Gem::Version.new("8.0.0") -%>
37
- params.expect(book: [:title])
38
- <% else -%>
39
- params.require(:book).permit(:title)
40
- <% end -%>
41
- end
42
- end
@@ -1,3 +0,0 @@
1
- class PagesController < ApplicationController
2
- def home; end
3
- end
@@ -1,19 +0,0 @@
1
- class Book
2
- include ActiveModel::Model
3
- include ActiveModel::Validations
4
- <% if Gem::Version.new(Rails.version) >= Gem::Version.new("6.0.0") -%>
5
- include ActiveModel::Attributes
6
-
7
- attribute :title, :string
8
- <% else -%>
9
-
10
- attr_accessor :title
11
-
12
- def initialize(attributes = {})
13
- super
14
- @title = attributes[:title]
15
- end
16
- <% end -%>
17
-
18
- validates :title, presence: true
19
- end
@@ -1,20 +0,0 @@
1
- <%= form_for(book, remote: true, html: { id: turbo_form_id }) do |form| %>
2
- <% if book.errors.any? %>
3
- <ul>
4
- <% book.errors.full_messages.each do |message| %>
5
- <li><%= message %></li>
6
- <% end %>
7
- </ul>
8
- <% end %>
9
-
10
- <%= hidden_field_tag :turbo_form_id, turbo_form_id %>
11
-
12
- <p>
13
- <%= form.label :title %>
14
- <%= form.text_field :title %>
15
- </p>
16
-
17
- <%= cloudflare_turnstile_tag data: {theme: 'dark'} %>
18
-
19
- <%= form.submit %>
20
- <% end %>
@@ -1,15 +0,0 @@
1
- (function() {
2
- const oldForm = document.getElementById("<%= j params[:turbo_form_id] %>");
3
- const newFormHTML = `<%= j render(partial: 'books/form', locals: { book: @book, turbo_form_id: params[:turbo_form_id] }) %>`;
4
-
5
- const tempWrapper = document.createElement("div");
6
- tempWrapper.innerHTML = newFormHTML;
7
- const newForm = tempWrapper.firstElementChild;
8
-
9
- oldForm.replaceWith(newForm);
10
-
11
- const turnstileContainer = newForm.querySelector('.cf-turnstile');
12
- if (typeof turnstile !== "undefined" && turnstileContainer && turnstileContainer.childElementCount === 0) {
13
- turnstile.render(turnstileContainer);
14
- }
15
- })();
@@ -1,5 +0,0 @@
1
- <p><%= notice %></p>
2
-
3
- <h1>New Book</h1>
4
-
5
- <%= render "form", book: @book, turbo_form_id: 'book-form' %>
@@ -1,9 +0,0 @@
1
- <p><%= notice %></p>
2
-
3
- <h1>New Book 1</h1>
4
-
5
- <%= render "form", book: @book1, turbo_form_id: 'book-form1' %>
6
-
7
- <h1>New Book 2</h1>
8
-
9
- <%= render "form", book: @book2, turbo_form_id: 'book-form2' %>
@@ -1,4 +0,0 @@
1
- <h1>Pages#home</h1>
2
- <p>Find me in app/views/pages/home.html.erb</p>
3
-
4
- <%= link_to 'New Book', new_book_path %>
@@ -1,44 +0,0 @@
1
- /**
2
- * This script improves UX for Rails 6 applications using Rails UJS + Turbolinks.
3
- *
4
- * Problem:
5
- * When submitting a form remotely (`remote: true`), if the server responds with
6
- * HTML (e.g., `render :new` or `render :edit` with validation errors),
7
- * Rails UJS fires `ajax:complete`, but nothing is automatically updated in the DOM.
8
- *
9
- * This results in poor UX — users see no feedback when form validation fails.
10
- *
11
- * Solution:
12
- * This listener catches AJAX responses that return full HTML content.
13
- * If the content type is `text/html`, we:
14
- * 1. Wrap the response in a Turbolinks snapshot.
15
- * 2. Cache the snapshot against the current URL.
16
- * 3. Trigger `Turbolinks.visit()` with `action: 'restore'` to re-render the page.
17
- *
18
- * This causes a soft reload (via Turbolinks) that displays the server-rendered
19
- * form with validation errors — giving users proper feedback without a full reload.
20
- */
21
-
22
- document.addEventListener('ajax:complete', event => {
23
- let referrer, snapshot;
24
- const xhr = event.detail[0];
25
-
26
- // Check if the response is HTML (e.g., a rendered form with errors)
27
- if ((xhr.getResponseHeader('Content-Type') || '').substring(0, 9) === 'text/html') {
28
- referrer = window.location.href;
29
-
30
- // Wrap the response in a Turbolinks snapshot
31
- snapshot = Turbolinks.Snapshot.wrap(xhr.response);
32
-
33
- // Store the snapshot in Turbolinks' cache
34
- Turbolinks.controller.cache.put(referrer, snapshot);
35
-
36
- // Revisit the current page to restore the updated form view with errors
37
- return Turbolinks.visit(referrer, {
38
- action: 'restore'
39
- });
40
- }
41
-
42
- // For non-HTML responses (e.g., JSON), do nothing
43
- return true;
44
- }, false);
@@ -1,4 +0,0 @@
1
- Cloudflare::Turnstile::Rails.configure do |config|
2
- config.site_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SITE_KEY', '1x00000000000000000000AA')
3
- config.secret_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SECRET_KEY', '1x0000000000000000000000000000000AA')
4
- end
@@ -1,7 +0,0 @@
1
- Rails.application.routes.draw do
2
- root 'pages#home'
3
-
4
- resources :books do
5
- get :new2, on: :collection
6
- end
7
- end
@@ -1,5 +0,0 @@
1
- require 'test_helper'
2
-
3
- class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
4
- driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
5
- end
@@ -1,19 +0,0 @@
1
- require 'test_helper'
2
-
3
- class BooksControllerTest < ActionDispatch::IntegrationTest
4
- test 'throws an exception when secret key is empty' do
5
- Cloudflare::Turnstile::Rails.configuration.secret_key = nil
6
-
7
- assert_raises ActionView::Template::Error, 'Cloudflare Turnstile secret_key is not set' do
8
- get new_book_url
9
- end
10
- end
11
-
12
- test 'throws an exception when site key is nil' do
13
- Cloudflare::Turnstile::Rails.configuration.site_key = nil
14
-
15
- assert_raises ActionView::Template::Error, 'Cloudflare Turnstile site_key is not set' do
16
- get new_book_url
17
- end
18
- end
19
- end
@@ -1,133 +0,0 @@
1
- require 'application_system_test_case'
2
-
3
- class BooksTest < ApplicationSystemTestCase
4
- setup do
5
- Cloudflare::Turnstile::Rails.configure do |config|
6
- config.site_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SITE_KEY', '1x00000000000000000000AA')
7
- config.secret_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SECRET_KEY', '1x0000000000000000000000000000000AA')
8
- end
9
- end
10
-
11
- test 'visiting the from from another page renders turnstile' do
12
- visit root_url
13
- click_on 'New Book'
14
- wait_for_turnstile_inputs(1)
15
-
16
- assert_selector "div.cf-turnstile input[name='cf-turnstile-response']", count: 1, visible: :all
17
- end
18
-
19
- test 'visiting the page twice does not render turnstile twice' do
20
- visit new_book_url
21
- wait_for_turnstile_inputs(1)
22
- visit new_book_url
23
- wait_for_turnstile_inputs(1)
24
-
25
- assert_selector "div.cf-turnstile input[name='cf-turnstile-response']", count: 1, visible: :all
26
- end
27
-
28
- test 'submitting the form with a validation error re-renders turnstile' do
29
- visit new_book_url
30
- wait_for_turnstile_inputs(1)
31
- click_on 'Create Book'
32
-
33
- assert_text "Title can't be blank"
34
- wait_for_turnstile_inputs(1)
35
- end
36
-
37
- test 'submitting the form with a valid book re-renders turnstile' do
38
- visit new_book_url
39
- wait_for_turnstile_inputs(1)
40
- fill_in 'Title', with: "Wizard's First Rule"
41
- click_on 'Create Book'
42
-
43
- assert_text 'Book was successfully created'
44
- wait_for_turnstile_inputs(1)
45
- end
46
-
47
- test 'submitting the form before turnstile is ready shows an error and re-renders turnstile' do
48
- visit new_book_url
49
- click_on 'Create Book'
50
-
51
- assert_text 'Turnstile verification missing.'
52
- wait_for_turnstile_inputs(1)
53
- end
54
-
55
- test 'turnstile does not render when site key is invalid' do
56
- Cloudflare::Turnstile::Rails.configuration.site_key = 'DUMMY'
57
- visit new_book_url
58
-
59
- assert_no_selector(turnstile_selector, visible: :all, wait: 5)
60
- end
61
-
62
- test 'turnstile returns an error when secret key is invalid' do
63
- Cloudflare::Turnstile::Rails.configuration.secret_key = 'DUMMY'
64
- visit new_book_url
65
- wait_for_turnstile_inputs(1)
66
- click_on 'Create Book'
67
-
68
- assert_text 'Server misconfiguration: Turnstile secret key invalid.'
69
- wait_for_turnstile_inputs(1)
70
- end
71
-
72
- test 'turnstile validation fails when human verification fails' do
73
- Cloudflare::Turnstile::Rails.configuration.secret_key = '2x0000000000000000000000000000000AA'
74
- visit new_book_url
75
- wait_for_turnstile_inputs(1)
76
- click_on 'Create Book'
77
-
78
- assert_text 'Turnstile token is invalid.'
79
- wait_for_turnstile_inputs(1)
80
- end
81
-
82
- test 'turnstile validation fails when the token is expired' do
83
- Cloudflare::Turnstile::Rails.configuration.secret_key = '3x0000000000000000000000000000000AA'
84
- visit new_book_url
85
- wait_for_turnstile_inputs(1)
86
- click_on 'Create Book'
87
-
88
- assert_text 'Turnstile token has already been used or expired.'
89
- wait_for_turnstile_inputs(1)
90
- end
91
-
92
- test 'turnstile renders two plugins when there are two forms' do
93
- skip "Not supported in Github actions for Ruby v#{RUBY_VERSION}" if RUBY_VERSION < '2.7.0' && ENV['CI']
94
-
95
- visit new2_books_url
96
- wait_for_turnstile_inputs(2)
97
- all('input[type="submit"]').each { |input| input.click and wait_for_turnstile_inputs(2) }
98
-
99
- assert_selector 'li', text: "Title can't be blank", count: 2, wait: 5
100
- end
101
-
102
- private
103
-
104
- def turnstile_selector
105
- "div.cf-turnstile input[name='cf-turnstile-response'][value*='DUMMY']"
106
- end
107
-
108
- def wait_for_turnstile_inputs(count, timeout: 5) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
109
- start = Time.now
110
- stable_since = nil
111
-
112
- loop do
113
- inputs = all("div.cf-turnstile input[name='cf-turnstile-response']", visible: :all)
114
- size = inputs.size
115
-
116
- if size == count && inputs.all? { |i| i.value.to_s.strip != '' }
117
- # once we hit the desired size with nonempty values,
118
- # wait a moment to make sure it’s stable
119
- stable_since ||= Time.now
120
- return if Time.now - stable_since > 0.5
121
- elsif size > count
122
- flunk "Expected #{count} Turnstile widgets, but found #{size}"
123
- else
124
- # reset the stability countdown if size changed
125
- stable_since = nil
126
- end
127
-
128
- flunk "Timed out waiting for #{count} Turnstile widgets; saw #{size}" if Time.now - start > timeout
129
-
130
- sleep 0.1
131
- end
132
- end
133
- end
@@ -1,81 +0,0 @@
1
- # 1) allow `copy_file`/`directory` to find files in templates/shared
2
- shared = File.expand_path('shared', __dir__)
3
- source_paths.unshift(shared)
4
-
5
- # 2) inject our gem under test into the Gemfile
6
- append_to_file 'Gemfile', <<~RUBY
7
- gem 'appraisal', require: false
8
- gem 'minitest-retry', require: false
9
-
10
- if RUBY_VERSION >= '3.0.0'
11
- # Include gems that are no longer loaded from standard libraries
12
- gem 'mutex_m'
13
- gem 'bigdecimal'
14
- gem 'drb'
15
- gem 'benchmark'
16
- end
17
-
18
- # Resolve the "uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)" issue
19
- gem 'concurrent-ruby', '< 1.3.5'
20
-
21
- #{if Rails::VERSION::STRING < '7.0.0'
22
- "# Higher versions are unsupported in Rails < 7.0.0\n# gem 'minitest', '< 5.12'"
23
- end}#{' '}
24
- #{if Rails::VERSION::STRING < '7.2.0'
25
- "# Higher versions cause 'uninitialized constant Rack::Handler (NameError)'\ngem 'rack', '< 3.0.0'"
26
- end}#{' '}
27
-
28
- if RUBY_VERSION >= '3.1.0'
29
- # Resolve the "Unknown alias: default (Psych::BadAlias)" error
30
- gem 'psych', '< 4'
31
- end
32
-
33
- #{if Rails::VERSION::STRING.start_with?('5.2.')
34
- "# Required for Rails 5.2, unsupported in older versions, and deprecated in newer versions\ngem 'webdrivers'"
35
- end}#{' '}
36
-
37
- # test against the local checkout of cloudflare-turnstile-rails
38
- gem 'cloudflare-turnstile-rails', path: "#{File.expand_path('..', __dir__)}"
39
- RUBY
40
-
41
- # 3) copy over all the shared app files
42
- %w[
43
- app/controllers/pages_controller.rb
44
- app/controllers/books_controller.rb.tt
45
- app/models/book.rb.tt
46
- app/views/pages/home.html.erb
47
- app/views/books/create.js.erb
48
- app/views/books/_form.html.erb
49
- app/views/books/new.html.erb
50
- app/views/books/new2.html.erb
51
- config/initializers/cloudflare_turnstile.rb
52
- config/routes.rb
53
- test/application_system_test_case.rb
54
- test/controllers/books_controller_test.rb
55
- test/system/books_test.rb
56
- ].each do |shared_path|
57
- if shared_path.end_with?('.tt')
58
- template shared_path, shared_path.sub(/\.tt$/, ''), force: true
59
- else
60
- copy_file shared_path, shared_path, force: true
61
- end
62
- end
63
-
64
- # 4) configure minitest-retry in test_helper.rb
65
- gsub_file 'test/test_helper.rb', %r{require ['"]rails/test_help['"]\n},
66
- "\\0require 'minitest/retry'\nMinitest::Retry.use!\n"
67
-
68
- # 5) turbo AJAX-cache helper
69
- packer_js = 'app/javascript/packs/application.js'
70
- if File.exist?(packer_js)
71
- copy_file 'cloudflare_turbolinks_ajax_cache.js', 'app/javascript/packs/cloudflare_turbolinks_ajax_cache.js',
72
- force: true
73
-
74
- # import it at the very bottom of application.js
75
- import_line = "\n// restore cached pages on AJAX navigations\n" \
76
- "import './cloudflare_turbolinks_ajax_cache'\n"
77
- append_to_file packer_js, import_line
78
- end
79
-
80
- # 6) Remove any existing chromedriver-helper gem line from Gemfile (only relevant for Rails 5.x)
81
- gsub_file 'Gemfile', /^\s*gem ['"]chromedriver-helper['"].*\n/, ''