Module: Turnstile

Defined in:
lib/turnstile.rb

Overview

Cloudflare Turnstile (CAPTCHA-style bot challenge) integration.
Pulls site/secret keys from env vars or Heatwave::Configuration and
verifies challenge tokens against Cloudflare's siteverify endpoint.
Used to gate public form submissions (signup, contact, quote
requests) without disrupting the user with a visible CAPTCHA.

Constant Summary collapse

SITE_KEY =

Key used for site.

raw_site
SECRET_KEY =

Key used for secret.

raw_secret
ENABLED =

Enabled.

(raw_enabled.nil? ? 'true' : raw_enabled.to_s) == 'true'
VERIFY_URL =

URL for verify.

'https://challenges.cloudflare.com/turnstile/v0/siteverify'.freeze

Class Method Summary collapse

Class Method Details

.enabled?Boolean

Returns:

  • (Boolean)


25
26
27
28
29
30
# File 'lib/turnstile.rb', line 25

def enabled?
  # Disable Turnstile entirely in development - production keys don't work on localhost
  return false if Rails.env.development?

  ENABLED && SITE_KEY.present? && SECRET_KEY.present?
end

.lazy_widget(options = {}) ⇒ Object

Lazy-loaded widget using Stimulus controller and IntersectionObserver
Only loads Turnstile script when the container becomes visible in viewport

Options:

  • theme: 'light' (default), 'dark', or 'auto'
  • size: 'normal' (default), 'flexible', or 'compact'
  • root_margin: IntersectionObserver margin (default: '200px')
  • class: Additional CSS classes for the container

Example:
<%= turnstile_lazy_widget(theme: 'dark') %>



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/turnstile.rb', line 54

def lazy_widget(options = {})
  theme = options.delete(:theme) || 'light'
  size = options.delete(:size) || 'normal'
  root_margin = options.delete(:root_margin) || '200px'
  css_class = options.delete(:class) || ''

  attrs = {
    'data-controller' => 'turnstile',
    'data-turnstile-sitekey-value' => SITE_KEY,
    'data-turnstile-theme-value' => theme,
    'data-turnstile-size-value' => size,
    'data-turnstile-root-margin-value' => root_margin
  }

  attrs['class'] = css_class if css_class.present?

  %(<div #{attrs.map { |k, v| %(#{k}="#{ERB::Util.html_escape(v)}") }.join(' ')}></div>).html_safe
end

.script_tag(nonce: nil) ⇒ Object

View helpers (called via helper_method)



33
34
35
# File 'lib/turnstile.rb', line 33

def script_tag(nonce: nil)
  %(<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer#{" nonce=\"#{ERB::Util.html_escape(nonce)}\"" if nonce}></script>).html_safe
end

.verify(response_token, remote_ip: nil) ⇒ Object

Server-side verification



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/turnstile.rb', line 74

def verify(response_token, remote_ip: nil)
  return true unless enabled?

  payload = { secret: SECRET_KEY, response: response_token }
  payload[:remoteip] = remote_ip if remote_ip
  res = Faraday.new do |f|
    f.request :url_encoded
    f.adapter Faraday.default_adapter
  end.post(VERIFY_URL, payload)
  body = begin
    JSON.parse(res.body)
  rescue StandardError
    { 'success' => false }
  end
  body['success'] == true
end

.widget(options = {}) ⇒ Object



37
38
39
40
# File 'lib/turnstile.rb', line 37

def widget(options = {})
  attrs = { 'class' => 'cf-turnstile', 'data-sitekey' => SITE_KEY }.merge(options.transform_keys { |k| "data-#{k}" })
  %(<div #{attrs.map { |k, v| %(#{k}="#{ERB::Util.html_escape(v)}") }.join(' ')}></div>).html_safe
end