hostily-captcha 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +74 -0
- data/lib/hostily/captcha/client.rb +113 -0
- data/lib/hostily/captcha/rails.rb +72 -0
- data/lib/hostily/captcha/version.rb +5 -0
- data/lib/hostily/captcha.rb +35 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8647978872b4be39152495b93f3d573eaf0a05fef1f56edb6c921339a90d8c3b
|
|
4
|
+
data.tar.gz: cc9099a1f4326fcff2cbfd6b1acfa4d37c5fe190ee4a46952d8c185ffeeb0b3c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 171ff9e945d6fc22013145bddf1e3a542bb7c02fa2af3c1598cf7d6c84dca46bef8a950661caefb0882838ffe9f6b709daee206109115bdcc362eb616169b2ac
|
|
7
|
+
data.tar.gz: 8db216a736510f450334cd9afeb3f2773da9bd0a628e2799d48c57df23e0afe30d5b9edaa03dc31eba6ac1b84e0ec03cbfb642fedd1ef5e2499853781c5ef067
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 connect - Media & Teledienst (hostily)
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# hostily-captcha (Ruby)
|
|
2
|
+
|
|
3
|
+
**Deutsch**
|
|
4
|
+
|
|
5
|
+
Kleiner Client für [hostily Captcha](https://captcha.hostily.de) mit Rails-Helfern. Ruby ≥ 3.1, nur `Net::HTTP` (keine fremden Gems).
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "hostily-captcha"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Ohne Rails**
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "hostily/captcha"
|
|
15
|
+
|
|
16
|
+
captcha = Hostily::Captcha::Client.new(ENV.fetch("HOSTILY_CAPTCHA_SECRET")) # Secret nur auf dem Server!
|
|
17
|
+
begin
|
|
18
|
+
result = captcha.verify_form(params, remoteip: request.ip)
|
|
19
|
+
rescue Hostily::Captcha::UnavailableError
|
|
20
|
+
# Dienst nicht erreichbar: blockieren oder durchlassen - Ihre Entscheidung
|
|
21
|
+
end
|
|
22
|
+
puts result.gruende, result.punkte unless result.success?
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Rails** (`config/initializers/hostily_captcha.rb`)
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
Hostily::Captcha.configure do |c|
|
|
29
|
+
c.site_key = "…" # öffentlich
|
|
30
|
+
c.secret = ENV["HOSTILY_CAPTCHA_SECRET"] # nur auf dem Server
|
|
31
|
+
# c.endpoint = "https://captcha.hostily.de" # Vorgabe
|
|
32
|
+
# c.modus = "sichtbar" | "unsichtbar"; c.sprache = "de" | "en" (Vorgabe: Seitensprache)
|
|
33
|
+
# c.fail_open = true # Dienst down: durchlassen (Vorgabe); false = blockieren
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```erb
|
|
38
|
+
<%= form_with url: contact_path do |f| %>
|
|
39
|
+
…
|
|
40
|
+
<%= hostily_captcha_tag %> <%# Widget + Skript; Optionen: site_key:, modus:, sprache:, api:, script: false %>
|
|
41
|
+
<% end %>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
def create
|
|
46
|
+
return render(:new, status: :unprocessable_entity) unless verify_hostily_captcha
|
|
47
|
+
…
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`verify_hostily_captcha(fail_open: nil, send_content: true)` liefert `true` (weiter) oder `false`. Ohne Site-Key und Secret wird
|
|
52
|
+
nichts geprüft. Ist der Dienst nicht erreichbar oder das Secret falsch, gilt `fail_open` und die Meldung steht im Rails-Log (nie das
|
|
53
|
+
Secret). `hostily_captcha_result` gibt das letzte Ergebnis zurück. Hinter einem Proxy liefert `request.remote_ip` die echte Adresse.
|
|
54
|
+
|
|
55
|
+
* `token_from(form)` / `content(form)`: Letzteres entfernt Passwortfelder, Secrets, CSRF-Token, Kartencodes und die Token-Felder,
|
|
56
|
+
auch in verschachtelten Hashes (`ActionController::Parameters` wird unterstützt). Passwörter werden nie mitgeschickt.
|
|
57
|
+
* `Result`: `success?`, `gruende`, `punkte`, `error_codes`, `hostname`, `configuration_error?`.
|
|
58
|
+
* `timeout:` in Sekunden (Vorgabe 5). Ohne Token wird der Dienst nicht gefragt (`token_fehlt`). Ein Token gilt nur für eine Prüfung.
|
|
59
|
+
* Getestet mit Ruby 3.1 und 3.4; Rails-Helfer mit actionpack (aktuell). Tests: `rake test`.
|
|
60
|
+
|
|
61
|
+
**English**
|
|
62
|
+
|
|
63
|
+
Small client for [hostily Captcha](https://captcha.hostily.de) with Rails helpers. Ruby ≥ 3.1, `Net::HTTP` only (no third-party gems).
|
|
64
|
+
|
|
65
|
+
* Without Rails: `Hostily::Captcha::Client.new(secret).verify_form(params, remoteip: ip)`; `UnavailableError` means the service is
|
|
66
|
+
unreachable / timed out / HTTP 5xx or 429 / unreadable answer - you decide to block or let pass.
|
|
67
|
+
* Rails: configure in an initializer (see above), put `<%= hostily_captcha_tag %>` into the form, call `verify_hostily_captcha` in the
|
|
68
|
+
controller (`true` = go on, `false` = reject). Nothing is checked without site key and secret; on an outage or a wrong secret
|
|
69
|
+
`fail_open` decides (default: let pass) and a line goes to the Rails log (never the secret).
|
|
70
|
+
* `content(form)` drops password fields, secrets, CSRF tokens, card codes and the token fields (also nested). Passwords are never sent.
|
|
71
|
+
* `Result`: `success?`, `gruende`, `punkte`, `error_codes`, `hostname`, `configuration_error?`. Default timeout 5 s.
|
|
72
|
+
A token is valid for one check only. Tests: `rake test`.
|
|
73
|
+
|
|
74
|
+
MIT License.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Hostily
|
|
7
|
+
module Captcha
|
|
8
|
+
DEFAULT_ENDPOINT = "https://captcha.hostily.de".freeze
|
|
9
|
+
TOKEN_FIELDS = %w[hostily-captcha-token g-recaptcha-response].freeze
|
|
10
|
+
SENSITIVE = /pass(word|wort|wd|phrase|code)|(^|[_\-\[])pass($|[_\-\]])|pwd|secret|csrf|xsrf|token|cvc|cvv/i
|
|
11
|
+
|
|
12
|
+
# The service could not be reached or answered unusably (network, timeout, HTTP 5xx/429, no JSON).
|
|
13
|
+
# The caller decides: let the request pass (fail open) or block it (fail closed).
|
|
14
|
+
class UnavailableError < StandardError; end
|
|
15
|
+
|
|
16
|
+
Result = Struct.new(:success, :gruende, :punkte, :error_codes, :hostname, :challenge_ts, keyword_init: true) do
|
|
17
|
+
def self.from_hash(h)
|
|
18
|
+
new(success: h["success"] == true, gruende: Array(h["gruende"]).map(&:to_s), punkte: h["punkte"],
|
|
19
|
+
error_codes: Array(h["error-codes"]).map(&:to_s), hostname: h["hostname"].to_s, challenge_ts: h["challenge_ts"])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def success? = success
|
|
23
|
+
|
|
24
|
+
# Secret missing or wrong: a configuration problem, not a spam verdict.
|
|
25
|
+
def configuration_error?
|
|
26
|
+
!(error_codes & %w[missing-input-secret invalid-input-secret]).empty?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Token from a params-like object (Hash, ActionController::Parameters, ...).
|
|
31
|
+
def self.token_from(form)
|
|
32
|
+
TOKEN_FIELDS.each do |name|
|
|
33
|
+
v = form[name]
|
|
34
|
+
v = form[name.to_sym] if v.nil? && form.respond_to?(:key?) && form.key?(name.to_sym)
|
|
35
|
+
return v.strip if v.is_a?(String) && !v.strip.empty?
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Form content for rule/honeypot checks. Token fields and anything that looks like a password, secret,
|
|
41
|
+
# CSRF token or card code are removed (also in nested hashes).
|
|
42
|
+
def self.content(form, exclude = [])
|
|
43
|
+
form = form.to_unsafe_h if form.respond_to?(:to_unsafe_h)
|
|
44
|
+
out = {}
|
|
45
|
+
form.each do |name, value|
|
|
46
|
+
key = name.to_s
|
|
47
|
+
next if TOKEN_FIELDS.include?(key) || exclude.include?(key) || SENSITIVE.match?(key)
|
|
48
|
+
|
|
49
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
50
|
+
out[key] =
|
|
51
|
+
case value
|
|
52
|
+
when Hash then content(value, exclude)
|
|
53
|
+
when Array then value.map { |v| v.is_a?(Hash) ? content(v, exclude) : v.to_s }
|
|
54
|
+
when String, Numeric, true, false, nil then value.to_s
|
|
55
|
+
else next
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
out
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Client
|
|
62
|
+
attr_reader :endpoint, :timeout
|
|
63
|
+
|
|
64
|
+
def initialize(secret, endpoint: DEFAULT_ENDPOINT, timeout: 5.0)
|
|
65
|
+
@secret = secret
|
|
66
|
+
@endpoint = endpoint.to_s.sub(%r{/+\z}, "")
|
|
67
|
+
@timeout = timeout
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Raises UnavailableError if the service cannot be reached.
|
|
71
|
+
def verify(token, remoteip: nil, formular: nil)
|
|
72
|
+
if token.nil? || token.to_s.strip.empty?
|
|
73
|
+
return Result.new(success: false, gruende: ["token_fehlt"], punkte: nil, error_codes: ["missing-input-response"], hostname: "", challenge_ts: nil)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
body = { "secret" => @secret, "response" => token.to_s.strip }
|
|
77
|
+
body["remoteip"] = remoteip if remoteip && !remoteip.to_s.empty?
|
|
78
|
+
body["formular"] = formular unless formular.nil?
|
|
79
|
+
status, raw = post(JSON.generate(body))
|
|
80
|
+
data = begin
|
|
81
|
+
JSON.parse(raw)
|
|
82
|
+
rescue JSON::ParserError, TypeError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
if status >= 500 || status == 429 || !data.is_a?(Hash) || !data.key?("success")
|
|
86
|
+
raise UnavailableError, "hostily Captcha answered unusably (HTTP #{status})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
Result.from_hash(data)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def verify_form(form, remoteip: nil, send_content: true)
|
|
93
|
+
verify(Captcha.token_from(form), remoteip: remoteip, formular: send_content ? Captcha.content(form) : nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def post(json)
|
|
99
|
+
uri = URI.parse("#{@endpoint}/api/v1/pruefen")
|
|
100
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
101
|
+
http.use_ssl = uri.scheme == "https"
|
|
102
|
+
http.open_timeout = http.read_timeout = http.write_timeout = @timeout
|
|
103
|
+
req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "Accept" => "application/json",
|
|
104
|
+
"User-Agent" => "hostily-captcha-ruby/#{VERSION}")
|
|
105
|
+
req.body = json
|
|
106
|
+
res = http.request(req)
|
|
107
|
+
[res.code.to_i, res.body.to_s]
|
|
108
|
+
rescue SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError, EOFError, Net::ProtocolError => e
|
|
109
|
+
raise UnavailableError, "hostily Captcha not reachable: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module Hostily
|
|
4
|
+
module Captcha
|
|
5
|
+
# View helper: <%= hostily_captcha_tag %> inside the <form>. Settings come from Hostily::Captcha.configuration.
|
|
6
|
+
module ViewHelper
|
|
7
|
+
# Widget element (and, unless script: false, the widget script). Options: site_key, modus, sprache, api.
|
|
8
|
+
def hostily_captcha_tag(site_key: nil, modus: nil, sprache: nil, api: nil, script: true)
|
|
9
|
+
c = Hostily::Captcha.configuration
|
|
10
|
+
key = site_key || c.site_key
|
|
11
|
+
return hostily_captcha_html("") if key.to_s.empty? # not configured: nothing to show
|
|
12
|
+
|
|
13
|
+
base = (api || c.endpoint).to_s.sub(%r{/+\z}, "")
|
|
14
|
+
attrs = { "data-site-key" => key, "data-api" => "#{base}/api/", "data-modus" => modus || c.modus }
|
|
15
|
+
sp = sprache || c.sprache
|
|
16
|
+
attrs["data-sprache"] = sp if sp
|
|
17
|
+
html = +""
|
|
18
|
+
html << %(<script src="#{CGI.escapeHTML(base)}/widget/hostily-captcha.js" defer></script>\n) if script
|
|
19
|
+
html << "<hostily-captcha" + attrs.map { |k, v| %( #{k}="#{CGI.escapeHTML(v.to_s)}") }.join + "></hostily-captcha>"
|
|
20
|
+
hostily_captcha_html(html)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def hostily_captcha_html(str)
|
|
26
|
+
str.respond_to?(:html_safe) ? str.html_safe : str
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Controller method: verify_hostily_captcha -> true (pass) / false (reject). Rejections are logged.
|
|
31
|
+
module ControllerMethods
|
|
32
|
+
# fail_open: nil takes Hostily::Captcha.configuration.fail_open. Without secret + site key nothing is checked.
|
|
33
|
+
def verify_hostily_captcha(fail_open: nil, send_content: true)
|
|
34
|
+
c = Hostily::Captcha.configuration
|
|
35
|
+
return true if c.secret.to_s.empty? || c.site_key.to_s.empty?
|
|
36
|
+
|
|
37
|
+
fail_open = c.fail_open if fail_open.nil?
|
|
38
|
+
result = Hostily::Captcha.client.verify_form(params, remoteip: request.remote_ip, send_content: send_content)
|
|
39
|
+
if result.configuration_error?
|
|
40
|
+
hostily_captcha_log(:error, "secret missing or wrong (#{result.error_codes.join(',')})")
|
|
41
|
+
return fail_open
|
|
42
|
+
end
|
|
43
|
+
@hostily_captcha_result = result
|
|
44
|
+
result.success?
|
|
45
|
+
rescue Hostily::Captcha::UnavailableError => e
|
|
46
|
+
hostily_captcha_log(:warn, e.message)
|
|
47
|
+
fail_open
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Result of the last check (nil if the service was down, not configured or the secret was wrong).
|
|
51
|
+
def hostily_captcha_result
|
|
52
|
+
@hostily_captcha_result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def hostily_captcha_log(level, message)
|
|
58
|
+
l = respond_to?(:logger, true) ? logger : nil
|
|
59
|
+
l&.public_send(level, "[hostily-captcha] #{message}") # never the secret
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if defined?(::Rails::Railtie)
|
|
64
|
+
class Railtie < ::Rails::Railtie
|
|
65
|
+
initializer "hostily_captcha.helpers" do
|
|
66
|
+
ActiveSupport.on_load(:action_view) { include Hostily::Captcha::ViewHelper }
|
|
67
|
+
ActiveSupport.on_load(:action_controller) { include Hostily::Captcha::ControllerMethods }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "hostily/captcha/version"
|
|
2
|
+
require "hostily/captcha/client"
|
|
3
|
+
require "hostily/captcha/rails"
|
|
4
|
+
|
|
5
|
+
module Hostily
|
|
6
|
+
module Captcha
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :secret, :site_key, :endpoint, :timeout, :modus, :sprache, :fail_open
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@endpoint = DEFAULT_ENDPOINT
|
|
12
|
+
@timeout = 5.0
|
|
13
|
+
@modus = "sichtbar"
|
|
14
|
+
@sprache = nil # nil: the widget decides (page language)
|
|
15
|
+
@fail_open = true # service down: let the request pass (false: block)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def configuration
|
|
21
|
+
@configuration ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure
|
|
25
|
+
yield configuration
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Client from the global configuration.
|
|
29
|
+
def client
|
|
30
|
+
c = configuration
|
|
31
|
+
Client.new(c.secret.to_s, endpoint: c.endpoint, timeout: c.timeout)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hostily-captcha
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- connect - Media & Teledienst
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Verify the token of a submitted form against hostily Captcha. Net::HTTP
|
|
14
|
+
only; optional Rails view helper and controller method.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/hostily/captcha.rb
|
|
23
|
+
- lib/hostily/captcha/client.rb
|
|
24
|
+
- lib/hostily/captcha/rails.rb
|
|
25
|
+
- lib/hostily/captcha/version.rb
|
|
26
|
+
homepage: https://captcha.hostily.de
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata:
|
|
30
|
+
source_code_uri: https://github.com/connect-gruppe/hostily-captcha-ruby
|
|
31
|
+
post_install_message:
|
|
32
|
+
rdoc_options: []
|
|
33
|
+
require_paths:
|
|
34
|
+
- lib
|
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.1'
|
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '0'
|
|
45
|
+
requirements: []
|
|
46
|
+
rubygems_version: 3.5.22
|
|
47
|
+
signing_key:
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Server-side client for hostily Captcha (captcha.hostily.de), with Rails helpers
|
|
50
|
+
test_files: []
|