otori 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8918f6c816e04317efede4302afa940ac592e92f6a4e8499bd81f9adbee536f0
4
+ data.tar.gz: c2659d173281ef798d54c52bcd2967dab83d971e3eb662db1ba214565824365a
5
+ SHA512:
6
+ metadata.gz: fbfb505eb0362b440bdbd0b86f53395dc4f3c1ff059e6446d2d7c8466c93c27f25e9b05889c62962292315bda17fcd09b5b98c434ed613e8bc5858fdf713ca89
7
+ data.tar.gz: a975548f46af0ec0ceb9d59fb73ef20a175211016156253414676e292f4c08d4b86fa7208aab98ed92ca42b61fc54e97b71f1ddc519dcebeaa61d6fc6b968216
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Initial scaffolding inspired by the
13
+ [Crystal lucky_honeypot shard](https://codeberg.org/fluck/lucky_honeypot).
14
+ - `Otori::Configuration` with `default_delay`, `disable_delay`, and
15
+ `signals_input_name`, accessed via `Otori.configure`.
16
+ - `Otori::Form.field` and `Otori::Form.signals_field` for
17
+ rendering the invisible honeypot input and the input-signals tracker as
18
+ framework-agnostic HTML strings.
19
+ - `Otori::Signals` for parsing the JSON payload submitted by the
20
+ tracker and computing a `human_rating` between 0 and 1.
21
+ - `Otori::Validator` with `filled?` and `elapsed?` for the two
22
+ core form checks.
23
+ - `Otori.caught?` combining the field and timing checks against a
24
+ session and params hash, with timestamp cleanup on success and reset on
25
+ failure.
26
+ - `Otori.signals_rating` convenience for computing the human rating
27
+ straight from a params hash.
28
+ - `Otori::Hanami::Action`, an optional adapter providing a
29
+ `honeypot` class DSL method that registers a Hanami `before` callback,
30
+ halting with 204 by default or running a user-supplied block.
31
+ - `Otori::Hanami::Helpers`, an optional view-helper module exposing
32
+ `honeypot_field` and `honeypot_signals` for Hanami views.
33
+ - `Otori::Error` and `Otori::MissingSession` exception
34
+ types.
35
+ - Codeberg / Forgejo CI workflow running rspec and rubocop on Ruby 3.4
36
+ and 4.0.
37
+ - README covering quickstart, framework integration for Hanami, Rails,
38
+ and any Rack app, configuration, signals interpretation, and security
39
+ considerations.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wout <hi@wout.codes>
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,319 @@
1
+ # Otori
2
+
3
+ [![CI](https://codeberg.org/fluck/otori/actions/workflows/ci.yml/badge.svg)](https://codeberg.org/fluck/otori/actions?workflow=ci.yml)
4
+ [![Version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fcodeberg.org%2Fapi%2Fv1%2Frepos%2Ffluck%2Fotori%2Ftags&query=%24%5B0%5D.name&label=version)](https://codeberg.org/fluck/otori/tags)
5
+
6
+ Invisible captcha spam protection for any Rack-based Ruby app, with an opt-in
7
+ Hanami adapter.
8
+
9
+ _Otori_ (囮) is the Japanese word for "decoy", historically a bird used in
10
+ hunting to draw others in.
11
+
12
+ This is a Ruby companion to the [Crystal lucky_honeypot
13
+ shard](https://codeberg.org/fluck/lucky_honeypot). It combines three classic
14
+ techniques into one gem:
15
+
16
+ 1. **Invisible fields**. Bots fill out every field, including ones hidden with CSS.
17
+ 2. **Timing checks**. Bots submit forms instantly, humans need more time.
18
+ 3. **Input signals**. Bots don't tend to trigger mouse, touch, scroll, keyboard, or focus events.
19
+
20
+ When either of the first two checks fail, the submission is quietly rejected.
21
+ The bot thinks it succeeded and moves on. The third one can be used to reject
22
+ or flag submissions at a chosen _human rating_ threshold.
23
+
24
+ > [!NOTE]
25
+ > The original repository is hosted at
26
+ > [Codeberg](https://codeberg.org/fluck/otori). The [GitHub
27
+ > repo](https://github.com/flucksite/otori) is just a mirror.
28
+
29
+ ## Installation
30
+
31
+ Add this to your Gemfile:
32
+
33
+ ```ruby
34
+ gem "otori"
35
+ ```
36
+
37
+ Then run `bundle install`. Ruby 3.2 or newer is required.
38
+
39
+ ## Quickstart
40
+
41
+ The gem ships a framework-agnostic core plus an opt-in Hanami adapter. The core
42
+ API is three calls:
43
+
44
+ ```ruby
45
+ Otori.field("user[website]", session: request.session)
46
+ Otori.signals_field
47
+ Otori.caught?("user[website]", params: request.params, session: request.session)
48
+ ```
49
+
50
+ `field` renders the invisible input and stores a load timestamp in the
51
+ session. `signals_field` renders a hidden input plus the JavaScript that
52
+ tracks human input. `caught?` checks the submitted form and returns `true`
53
+ when the request looks like a bot.
54
+
55
+ Field names use standard HTML bracket notation, so `"user[website]"` lives
56
+ under `params[:user][:website]` once submitted. Flat names like `"note"` work
57
+ too. Pick whichever fits the surrounding form, the more believable the
58
+ honeypot looks next to the real fields, the better.
59
+
60
+ ## Framework integration
61
+
62
+ ### Hanami
63
+
64
+ The Hanami adapter is loaded explicitly so the base gem stays dependency-free:
65
+
66
+ ```ruby
67
+ require "otori/hanami"
68
+ ```
69
+
70
+ Mix the helpers into your views:
71
+
72
+ ```ruby
73
+ # app/views/helpers.rb
74
+ module MyApp
75
+ module Views
76
+ module Helpers
77
+ include Otori::Hanami::Helpers
78
+ end
79
+ end
80
+ end
81
+ ```
82
+
83
+ In a form template:
84
+
85
+ ```erb
86
+ <%= honeypot_field("user[website]") %>
87
+ <%= honeypot_signals %>
88
+ ```
89
+
90
+ The helpers mark their output safe via `String#html_safe`, which Hanami View
91
+ provides out of the box, so the HTML flows through ERB without escaping.
92
+
93
+ Guard the receiving action with the `honeypot` DSL method:
94
+
95
+ ```ruby
96
+ # app/actions/sign_ups/create.rb
97
+ module MyApp
98
+ module Actions
99
+ module SignUps
100
+ class Create < MyApp::Action
101
+ include Otori::Hanami::Action
102
+
103
+ honeypot "user[website]"
104
+
105
+ def handle(request, response)
106
+ # ...
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ ```
113
+
114
+ When the honeypot is tripped, the action halts with `204 No Content` by
115
+ default. To customize the response, pass a block:
116
+
117
+ ```ruby
118
+ honeypot "user[website]" do |_request, response|
119
+ response.flash[:info] = "Moving on..."
120
+ response.redirect_to "/", status: 303
121
+ end
122
+ ```
123
+
124
+ Multiple honeypots are supported, each with its own timing and handler:
125
+
126
+ ```ruby
127
+ honeypot "user[website]", wait: 5
128
+ honeypot "note" do |_req, _res|
129
+ halt 422
130
+ end
131
+ ```
132
+
133
+ To act on the input-signals rating, evaluate it inside `handle`:
134
+
135
+ ```ruby
136
+ def handle(request, response)
137
+ rating = Otori.signals_rating(request.params.to_h)
138
+ halt 204 if rating < 0.4
139
+
140
+ # ...
141
+ end
142
+ ```
143
+
144
+ ### Rack (Sinatra, Roda, plain Rack)
145
+
146
+ There is no adapter to require, the core API is enough. In a Sinatra app:
147
+
148
+ ```ruby
149
+ require "otori"
150
+
151
+ enable :sessions
152
+
153
+ get "/sign_up" do
154
+ erb :sign_up
155
+ end
156
+
157
+ post "/sign_up" do
158
+ halt 204 if Otori.caught?(
159
+ "user[website]",
160
+ params: params,
161
+ session: session
162
+ )
163
+
164
+ # ...
165
+ end
166
+ ```
167
+
168
+ In the view:
169
+
170
+ ```erb
171
+ <%= Otori.field("user[website]", session: session) %>
172
+ <%= Otori.signals_field %>
173
+ ```
174
+
175
+ ### Rails
176
+
177
+ Rails is well served by [invisible_captcha](https://github.com/markets/invisible_captcha)
178
+ when all you need is a hidden field and a timing check. Use this gem in Rails
179
+ if you want the input-signals rating on top.
180
+
181
+ ```ruby
182
+ # app/helpers/application_helper.rb
183
+ module ApplicationHelper
184
+ def honeypot_field(name, **attrs)
185
+ Otori.field(name, session: session, **attrs).html_safe
186
+ end
187
+
188
+ def honeypot_signals(**attrs)
189
+ Otori.signals_field(**attrs).html_safe
190
+ end
191
+ end
192
+ ```
193
+
194
+ ```ruby
195
+ # app/controllers/sign_ups_controller.rb
196
+ class SignUpsController < ApplicationController
197
+ before_action :check_honeypot, only: :create
198
+
199
+ def create
200
+ # ...
201
+ end
202
+
203
+ private
204
+
205
+ def check_honeypot
206
+ return unless Otori.caught?(
207
+ "user[website]",
208
+ params: params.to_unsafe_h,
209
+ session: session
210
+ )
211
+
212
+ head :no_content
213
+ end
214
+ end
215
+ ```
216
+
217
+ ## Configuration
218
+
219
+ ```ruby
220
+ Otori.configure do |c|
221
+ # Required delay (in seconds) between page load and form submission.
222
+ c.default_delay = 2.0
223
+
224
+ # Disables the submission delay entirely. Useful in tests.
225
+ c.disable_delay = false
226
+
227
+ # Name of the hidden input that carries the signals payload.
228
+ c.signals_input_name = "honeypot_signals"
229
+ end
230
+ ```
231
+
232
+ ## The invisible field
233
+
234
+ By default the field is rendered with an inline `style` attribute that takes
235
+ it out of the visual flow without breaking accessibility tools. Pass your own
236
+ `class` (or `style`) to opt out of the default style and use your CSS instead:
237
+
238
+ ```ruby
239
+ Otori.field("user[website]", session: session, class: "visually-hidden")
240
+ ```
241
+
242
+ Underscored attribute keys are converted to dashes so `data_foo: "bar"`
243
+ renders as `data-foo="bar"`. Any other attribute pair is passed through
244
+ unchanged.
245
+
246
+ > [!NOTE]
247
+ > The field stores a load timestamp in the session under a
248
+ > `honeypot_field_<name>` key. The companion `caught?` call reads and
249
+ > clears it on success, or resets it when the form is rejected.
250
+
251
+ ## Detecting input signals
252
+
253
+ `signals_field` renders a hidden input plus a small inline `<script>` that
254
+ listens for the first occurrence of each of five events on the surrounding
255
+ form: `mousemove`, `touchstart`, `keydown`, `focusin`, and a window-level
256
+ `scroll`. On submit, the boolean results are serialized to JSON and posted
257
+ along with the rest of the form.
258
+
259
+ In the action, get the rating directly from the params:
260
+
261
+ ```ruby
262
+ Otori.signals_rating(params) # => 0.0 to 1.0
263
+ ```
264
+
265
+ Or work with the parsed object for more detail:
266
+
267
+ ```ruby
268
+ signals = Otori::Signals.from_json(params["honeypot_signals"])
269
+ signals.human_rating # 0 (bot) to 1 (human)
270
+ signals.mouse?
271
+ signals.touch?
272
+ signals.scroll?
273
+ signals.keyboard?
274
+ signals.focus?
275
+ ```
276
+
277
+ > [!NOTE]
278
+ > The human rating is the fraction of the five signals that fired, so each one
279
+ > contributes `0.2`. A score of `0` is almost certainly a dumb bot, while `0.2`
280
+ > could be a sophisticated bot triggering a single signal (almost always
281
+ > `mouse`), though a human filling out a short form at the top of the page may
282
+ > also land there.
283
+ >
284
+ > `0.4` is a reasonable threshold for flagging entries: it still catches bots
285
+ > that fake one or two signals, but avoids false positives for autofill and
286
+ > password manager submissions, which often only trigger focus plus mouse or
287
+ > touch.
288
+
289
+ ## Security considerations
290
+
291
+ This gem provides basic bot protection, but it should not be your only line
292
+ of defense.
293
+
294
+ - It is not foolproof, sophisticated bots can bypass honeypots.
295
+ - Combine this with a rate limiter such as `rack-attack`.
296
+ - For high-value forms, consider adding CAPTCHA or email verification.
297
+ - The submission timestamp is stored in the session. If sessions are
298
+ compromised, an attacker could manipulate timing checks. Make sure your session
299
+ store uses signed and encrypted cookies.
300
+ - The timing check compares wall-clock timestamps, which makes it resilient to
301
+ timing attacks since the check is a simple threshold comparison.
302
+ - This gem does not touch CSRF tokens. Honeypot fields are regular form inputs
303
+ and do not interfere with your framework's CSRF protection.
304
+
305
+ For most use cases (contact forms, newsletter signups), this gem provides solid
306
+ protection with zero user friction. Expect it to catch between 60% and 90% of
307
+ automated form submissions.
308
+
309
+ ## Development
310
+
311
+ ```bash
312
+ bundle install
313
+ bundle exec rspec
314
+ bundle exec rubocop
315
+ ```
316
+
317
+ ## License
318
+
319
+ MIT
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Otori
4
+ class Configuration
5
+ SESSION_KEY_PREFIX = "honeypot_field"
6
+
7
+ attr_accessor :default_delay, :disable_delay, :signals_input_name
8
+
9
+ def initialize
10
+ @default_delay = 2.0
11
+ @disable_delay = false
12
+ @signals_input_name = "honeypot_signals"
13
+ end
14
+
15
+ def session_key(name)
16
+ safe = name.to_s.gsub(/[^a-z0-9_]+/i, "_").gsub(/\A_+|_+\z/, "")
17
+ "#{SESSION_KEY_PREFIX}_#{safe}"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Otori
4
+ class Error < StandardError; end
5
+
6
+ class MissingSession < Error
7
+ def initialize
8
+ super(
9
+ "Otori needs a session-like object (responding to []=, []) " \
10
+ "to store the form-load timestamp. Enable sessions in your app."
11
+ )
12
+ end
13
+ end
14
+ end
data/lib/otori/form.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ require_relative "validator"
6
+
7
+ module Otori
8
+ module Form
9
+ extend self
10
+
11
+ HIDDEN_STYLE = "position:absolute;left:-9999px;width:1px;height:1px;pointer-events:none;"
12
+
13
+ SIGNALS_SCRIPT = <<~JS
14
+ (() => {
15
+ const s = { m: false, t: false, s: false, k: false, f: false };
16
+ const input = document.currentScript.previousElementSibling;
17
+ const form = input.form;
18
+ form.addEventListener('mousemove', () => s.m = true, { once: true });
19
+ form.addEventListener('touchstart', () => s.t = true, { once: true });
20
+ form.addEventListener('keydown', () => s.k = true, { once: true });
21
+ form.addEventListener('focusin', () => s.f = true, { once: true });
22
+ window.addEventListener('scroll', () => s.s = true, { once: true });
23
+ form.addEventListener('submit', () => input.value = JSON.stringify(s));
24
+ })();
25
+ JS
26
+
27
+ def field(name, session:, **attrs)
28
+ raise MissingSession unless session_like?(session)
29
+
30
+ session[Otori.config.session_key(name)] = Validator.monotonic_ms.to_s
31
+
32
+ base = {
33
+ name: name.to_s,
34
+ type: "text",
35
+ "aria-hidden": "true",
36
+ tabindex: "-1",
37
+ autocomplete: "off"
38
+ }
39
+ base[:style] = HIDDEN_STYLE unless attrs.key?(:class) || attrs.key?(:style)
40
+
41
+ tag(:input, base.merge(stringify_keys(attrs)))
42
+ end
43
+
44
+ def signals_field(**attrs)
45
+ input = tag(:input, {
46
+ name: Otori.config.signals_input_name,
47
+ type: "hidden"
48
+ }.merge(stringify_keys(attrs)))
49
+
50
+ "#{input}<script>#{SIGNALS_SCRIPT}</script>"
51
+ end
52
+
53
+ private
54
+
55
+ def session_like?(object)
56
+ object.respond_to?(:[]) && object.respond_to?(:[]=)
57
+ end
58
+
59
+ def stringify_keys(attrs)
60
+ attrs.to_h { |key, value| [key.to_s.tr("_", "-"), value] }
61
+ end
62
+
63
+ def tag(name, attrs)
64
+ pairs = attrs.compact.map do |key, value|
65
+ next CGI.escape_html(key.to_s) if value == true
66
+
67
+ %(#{CGI.escape_html(key.to_s)}="#{CGI.escape_html(value.to_s)}")
68
+ end
69
+ "<#{name} #{pairs.join(" ")}>"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../otori"
4
+
5
+ module Otori
6
+ module Hanami
7
+ module Action
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def honeypot(name, wait: nil, &on_caught)
14
+ field_name = name
15
+ field_wait = wait
16
+ caught_block = on_caught
17
+
18
+ before do |request, response|
19
+ next unless Otori.caught?(
20
+ field_name,
21
+ params: request.params.to_h,
22
+ session: request.session,
23
+ wait: field_wait
24
+ )
25
+
26
+ if caught_block
27
+ instance_exec(request, response, &caught_block)
28
+ else
29
+ halt 204
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ module Helpers
37
+ def honeypot_field(name, **attrs)
38
+ Otori.field(name, session: _otori_session, **attrs).html_safe
39
+ end
40
+
41
+ def honeypot_signals(**attrs)
42
+ Otori.signals_field(**attrs).html_safe
43
+ end
44
+
45
+ private
46
+
47
+ def _otori_session
48
+ return context.request.session if respond_to?(:context) &&
49
+ context.respond_to?(:request)
50
+ return request.session if respond_to?(:request)
51
+
52
+ raise Otori::MissingSession
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Otori
6
+ class Signals
7
+ KEYS = {
8
+ mouse: "m",
9
+ touch: "t",
10
+ scroll: "s",
11
+ keyboard: "k",
12
+ focus: "f"
13
+ }.freeze
14
+
15
+ def self.from_json(json)
16
+ raw = JSON.parse(json.to_s)
17
+ raise JSON::ParserError, "expected an object" unless raw.is_a?(Hash)
18
+
19
+ new(KEYS.transform_values { raw[_1] == true })
20
+ rescue JSON::ParserError
21
+ new
22
+ end
23
+
24
+ def self.human_rating(json)
25
+ from_json(json).human_rating
26
+ end
27
+
28
+ def initialize(flags = {})
29
+ @flags = KEYS.keys.to_h { [_1, flags.fetch(_1, false) == true] }
30
+ end
31
+
32
+ KEYS.each_key do |key|
33
+ define_method("#{key}?") { @flags.fetch(key) }
34
+ end
35
+
36
+ def human_rating
37
+ @flags.values.count(true) / KEYS.size.to_f
38
+ end
39
+
40
+ def to_h = @flags.dup
41
+ end
42
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Otori
4
+ module Validator
5
+ extend self
6
+
7
+ def filled?(value)
8
+ !value.nil? && !value.to_s.strip.empty?
9
+ end
10
+
11
+ def elapsed?(timestamp_ms, wait_seconds, now: monotonic_ms)
12
+ return true if Otori.config.disable_delay
13
+ return false if timestamp_ms.nil?
14
+
15
+ (now - timestamp_ms.to_i) >= (wait_seconds.to_f * 1000)
16
+ end
17
+
18
+ def monotonic_ms = (Time.now.to_f * 1000).to_i
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Otori
4
+ VERSION = "0.1.0"
5
+ end
data/lib/otori.rb ADDED
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "otori/version"
4
+ require_relative "otori/error"
5
+ require_relative "otori/configuration"
6
+ require_relative "otori/signals"
7
+ require_relative "otori/validator"
8
+ require_relative "otori/form"
9
+
10
+ module Otori
11
+ class << self
12
+ def config
13
+ @config ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield config
18
+ config
19
+ end
20
+
21
+ def reset_config!
22
+ @config = Configuration.new
23
+ end
24
+
25
+ def field(name, session:, **attrs)
26
+ Form.field(name, session: session, **attrs)
27
+ end
28
+
29
+ def signals_field(**attrs)
30
+ Form.signals_field(**attrs)
31
+ end
32
+
33
+ def caught?(name, params:, session:, wait: nil)
34
+ wait ||= config.default_delay
35
+ session_key = config.session_key(name)
36
+ stored = session_get(session, session_key)
37
+ filled = Validator.filled?(param_value(params, name))
38
+ elapsed = Validator.elapsed?(stored&.to_i, wait)
39
+
40
+ if !filled && elapsed
41
+ session_delete(session, session_key)
42
+ false
43
+ else
44
+ session[session_key] = Validator.monotonic_ms.to_s
45
+ true
46
+ end
47
+ end
48
+
49
+ def signals_rating(params)
50
+ raw = param_value(params, config.signals_input_name)
51
+ return 0.0 if raw.nil? || raw.to_s.empty?
52
+
53
+ Signals.human_rating(raw.to_s)
54
+ end
55
+
56
+ private
57
+
58
+ def param_value(params, name)
59
+ param_keys(name).reduce(params) do |scope, key|
60
+ break nil unless scope.respond_to?(:[])
61
+
62
+ scope[key] || scope[key.to_sym]
63
+ end
64
+ end
65
+
66
+ def param_keys(name)
67
+ keys = name.to_s.scan(/[^\[\]]+/)
68
+ keys.empty? ? [name.to_s] : keys
69
+ end
70
+
71
+ def session_get(session, key)
72
+ session[key] || session[key.to_sym]
73
+ end
74
+
75
+ def session_delete(session, key)
76
+ session.delete(key) if session.respond_to?(:delete)
77
+ end
78
+ end
79
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: otori
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wout
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ Drop-in honeypot spam protection for any Rack-based app. Combines an
14
+ invisible form field, a submission-timing check, and a JavaScript input
15
+ signals tracker (mouse, touch, scroll, keyboard, focus) into a single
16
+ framework-agnostic gem, with an opt-in Hanami adapter. Ruby companion to
17
+ the Crystal lucky_honeypot shard.
18
+ email:
19
+ - hi@wout.codes
20
+ executables: []
21
+ extensions: []
22
+ extra_rdoc_files: []
23
+ files:
24
+ - CHANGELOG.md
25
+ - LICENSE
26
+ - README.md
27
+ - lib/otori.rb
28
+ - lib/otori/configuration.rb
29
+ - lib/otori/error.rb
30
+ - lib/otori/form.rb
31
+ - lib/otori/hanami.rb
32
+ - lib/otori/signals.rb
33
+ - lib/otori/validator.rb
34
+ - lib/otori/version.rb
35
+ homepage: https://codeberg.org/fluck/otori
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ source_code_uri: https://codeberg.org/fluck/otori
40
+ bug_tracker_uri: https://codeberg.org/fluck/otori/issues
41
+ changelog_uri: https://codeberg.org/fluck/otori/src/branch/main/CHANGELOG.md
42
+ rubygems_mfa_required: 'true'
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '3.2'
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 4.0.11
58
+ specification_version: 4
59
+ summary: Invisible honeypot spam protection for Rack apps, with a Hanami adapter.
60
+ test_files: []