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.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -1
- data/README.md +54 -7
- data/lib/cloudflare/turnstile/rails/configuration.rb +2 -11
- data/lib/cloudflare/turnstile/rails/constants/{error_codes.rb → error_code.rb} +1 -1
- data/lib/cloudflare/turnstile/rails/constants/error_message.rb +26 -0
- data/lib/cloudflare/turnstile/rails/controller_methods.rb +12 -20
- data/lib/cloudflare/turnstile/rails/helpers.rb +0 -2
- data/lib/cloudflare/turnstile/rails/verification.rb +21 -18
- data/lib/cloudflare/turnstile/rails/version.rb +1 -1
- data/lib/generators/cloudflare_turnstile/templates/cloudflare_turnstile.rb +5 -0
- metadata +5 -20
- data/lib/cloudflare/turnstile/rails/constants/error_messages.rb +0 -22
- data/templates/shared/app/controllers/books_controller.rb.tt +0 -42
- data/templates/shared/app/controllers/pages_controller.rb +0 -3
- data/templates/shared/app/models/book.rb.tt +0 -19
- data/templates/shared/app/views/books/_form.html.erb +0 -20
- data/templates/shared/app/views/books/create.js.erb +0 -15
- data/templates/shared/app/views/books/new.html.erb +0 -5
- data/templates/shared/app/views/books/new2.html.erb +0 -9
- data/templates/shared/app/views/pages/home.html.erb +0 -4
- data/templates/shared/cloudflare_turbolinks_ajax_cache.js +0 -44
- data/templates/shared/config/initializers/cloudflare_turnstile.rb +0 -4
- data/templates/shared/config/routes.rb +0 -7
- data/templates/shared/test/application_system_test_case.rb +0 -5
- data/templates/shared/test/controllers/books_controller_test.rb +0 -19
- data/templates/shared/test/system/books_test.rb +0 -133
- data/templates/template.rb +0 -81
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ab251a17ed52bd0e141cbf139543e49b00fc786ddfec79e9ef8c47f8e52cd25b
|
4
|
+
data.tar.gz: 22fdeb19187f6a44b8f1ff0da9b40011e5cdd8f6cb51956821cbcc720bbe7a0f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
+
[](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
|
-
|
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
|
89
|
-
#
|
90
|
+
if valid_turnstile?(model: @user)
|
91
|
+
# Passed: returns true
|
90
92
|
else
|
91
|
-
#
|
93
|
+
# Failed: returns false, adds errors to @user
|
92
94
|
render :new, status: :unprocessable_entity
|
93
95
|
end
|
94
96
|
```
|
95
97
|
|
96
|
-
|
97
|
-
|
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
|
-
|
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
|
@@ -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/
|
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,
|
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
|
-
|
27
|
-
|
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
|
48
|
-
|
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
|
-
|
51
|
-
|
55
|
+
response = 'dummy-response'
|
56
|
+
end
|
52
57
|
|
53
|
-
|
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
|
-
|
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
|
-
|
71
|
-
|
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
|
@@ -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.
|
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:
|
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/
|
44
|
-
- lib/cloudflare/turnstile/rails/constants/
|
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.
|
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,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,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,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
|
data/templates/template.rb
DELETED
@@ -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/, ''
|