spamtrap 0.3.4 → 0.4.2

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.
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spamtrap
4
+ # Raised by spamtrap_nonce_fields when there's no request to bind the nonce to
5
+ # (mailers, ApplicationController.render outside a request, etc.).
6
+ class NoRequestError < StandardError; end
7
+
4
8
  module FormBuilderMutation
5
9
  include Spamtrap::Crypto
6
10
 
@@ -9,12 +13,15 @@ module Spamtrap
9
13
  text_area check_box hidden_field file_field date_field time_field
10
14
  datetime_local_field month_field week_field search_field color_field
11
15
  range_field select collection_select grouped_collection_select label
16
+ radio_button phone_field datetime_field time_zone_select weekday_select
17
+ collection_check_boxes collection_radio_buttons date_select time_select
18
+ datetime_select rich_text_area
12
19
  ].freeze
13
20
 
14
21
  MUTABLE_FIELDS.each do |m|
15
22
  define_method(m) do |field, *args, &blk|
16
- if @spamtrap_salt
17
- encrypted_field = spamtrap_encrypt_field(field.to_s, @spamtrap_salt)
23
+ if @spamtrap_timestamp
24
+ encrypted_field = spamtrap_token_for(field)
18
25
 
19
26
  opts =
20
27
  if m == :check_box
@@ -25,45 +32,102 @@ module Spamtrap
25
32
 
26
33
  model_value = object.respond_to?(field) ? object.public_send(field) : nil
27
34
 
28
- case m
29
- when :check_box
30
- # :checked controls the checked state; :value is the submitted value ("1" by default)
31
- opts[:checked] = !!model_value unless opts.key?(:checked)
32
- super(encrypted_field, opts, *args, &blk)
33
- when :select, :collection_select, :grouped_collection_select
34
- # :selected belongs in the inner options hash, not html_options (the last hash).
35
- # After popping html_options into opts, args.last is the options hash (if present).
36
- if args.last.is_a?(Hash)
37
- sel_opts = args.pop.dup
38
- sel_opts[:selected] = model_value unless sel_opts.key?(:selected)
39
- args.push(sel_opts)
35
+ html =
36
+ case m
37
+ when :check_box
38
+ spamtrap_apply_model_value(m, opts, model_value)
39
+ opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
40
+ super(encrypted_field, opts, *args, &blk)
41
+ when :radio_button
42
+ tag_value = args.first
43
+ spamtrap_apply_model_value(m, opts, model_value, tag_value)
44
+ opts[:id] = spamtrap_radio_id(field, tag_value) if Spamtrap.stable_ids && !opts.key?(:id)
45
+ super(encrypted_field, *args, opts, &blk)
46
+ when :select, :collection_select, :grouped_collection_select, :weekday_select
47
+ # :selected belongs in the inner options hash, not html_options (the last hash).
48
+ # After popping html_options into opts, args.last is the options hash (if present).
49
+ if args.last.is_a?(Hash)
50
+ sel_opts = args.pop.dup
51
+ spamtrap_apply_model_value(m, sel_opts, model_value)
52
+ args.push(sel_opts)
53
+ else
54
+ spamtrap_apply_model_value(m, opts, model_value)
55
+ end
56
+ opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
57
+ super(encrypted_field, *args, opts, &blk)
58
+ when :time_zone_select
59
+ # Unlike the others above, priority_zones sits before options; pad it back in
60
+ # when the caller omitted it entirely so opts lands as options, not priority_zones.
61
+ if args.last.is_a?(Hash)
62
+ sel_opts = args.pop.dup
63
+ spamtrap_apply_model_value(m, sel_opts, model_value)
64
+ args.push(sel_opts)
65
+ else
66
+ args.push(nil) if args.empty?
67
+ spamtrap_apply_model_value(m, opts, model_value)
68
+ end
69
+ opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
70
+ super(encrypted_field, *args, opts, &blk)
71
+ when :collection_check_boxes, :collection_radio_buttons
72
+ # Rails computes each collection item's own <input> id internally; left opaque.
73
+ if args.last.is_a?(Hash)
74
+ sel_opts = args.pop.dup
75
+ spamtrap_apply_model_value(m, sel_opts, model_value)
76
+ args.push(sel_opts)
77
+ else
78
+ spamtrap_apply_model_value(m, opts, model_value)
79
+ end
80
+ super(encrypted_field, *args, opts, &blk)
81
+ when :date_select, :time_select, :datetime_select
82
+ # Rails derives each <select>'s id from its multiparameter suffix (e.g. "(1i)"); left opaque.
83
+ if args.last.is_a?(Hash)
84
+ sel_opts = args.pop.dup
85
+ spamtrap_apply_model_value(m, sel_opts, model_value)
86
+ args.push(sel_opts)
87
+ else
88
+ spamtrap_apply_model_value(m, opts, model_value)
89
+ end
90
+ super(encrypted_field, *args, opts, &blk)
91
+ when :label
92
+ # Preserve the human-readable label text using the real field name
93
+ # before substituting the encrypted form. Mirrors Rails' own
94
+ # resolution order:
95
+ # 1. explicit text argument — kept as-is
96
+ # 2. activerecord.attributes.Model.field (ActiveRecord models)
97
+ # 3. helpers.label.object_name.field (plain objects / form_with)
98
+ # 4. humanize fallback
99
+ # Discard any explicit nil that a subclass may have passed through
100
+ # (e.g. `super(method, nil, options)`) so it does not survive as a
101
+ # spurious positional argument in the final super call.
102
+ args.compact!
103
+ unless args.first.is_a?(String)
104
+ human_text =
105
+ if object.respond_to?(:to_model)
106
+ object.class.human_attribute_name(field.to_s, default: field.to_s.humanize)
107
+ else
108
+ I18n.t(field, scope: [:helpers, :label, object_name], default: field.to_s.humanize)
109
+ end
110
+ args.unshift(human_text)
111
+ end
112
+ opts[:for] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:for)
113
+ super(encrypted_field, *args, opts, &blk)
114
+ when :rich_text_area
115
+ # Action Text may not be loaded, so FormBuilder won't define this method at all;
116
+ # `super` would then raise a confusing "super: no superclass method" instead of
117
+ # a normal NoMethodError naming the method the caller actually tried to call.
118
+ spamtrap_apply_model_value(m, opts, model_value)
119
+ if defined?(super)
120
+ super(encrypted_field, *args, opts, &blk)
121
+ else
122
+ raise NoMethodError, "undefined method `#{m}' for an instance of #{self.class}"
123
+ end
40
124
  else
41
- opts[:selected] = model_value unless opts.key?(:selected)
125
+ spamtrap_apply_model_value(m, opts, model_value)
126
+ opts[:id] = field_id(field) if Spamtrap.stable_ids && !opts.key?(:id)
127
+ super(encrypted_field, *args, opts, &blk)
42
128
  end
43
129
 
44
- super(encrypted_field, *args, opts, &blk)
45
- when :label
46
- # Preserve the human-readable label text using the real field name
47
- # before substituting the encrypted form. Mirrors Rails' own
48
- # resolution order:
49
- # 1. explicit text argument — kept as-is
50
- # 2. activerecord.attributes.Model.field (ActiveRecord models)
51
- # 3. helpers.label.object_name.field (plain objects / form_with)
52
- # 4. humanize fallback
53
- unless args.first.is_a?(String)
54
- human_text =
55
- if object.respond_to?(:to_model)
56
- object.class.human_attribute_name(field.to_s, default: field.to_s.humanize)
57
- else
58
- I18n.t(field, scope: [:helpers, :label, object_name], default: field.to_s.humanize)
59
- end
60
- args.unshift(human_text)
61
- end
62
- super(encrypted_field, *args, opts, &blk)
63
- else
64
- opts[:value] = model_value unless opts.key?(:value)
65
- super(encrypted_field, *args, opts, &blk)
66
- end
130
+ spamtrap_error_wrap(html, field)
67
131
  else
68
132
  super(field, *args, &blk)
69
133
  end
@@ -72,51 +136,174 @@ module Spamtrap
72
136
 
73
137
  def initialize(object_name, object, template, options)
74
138
  super
75
- @spamtrap_salt = options[:spamtrap_salt] if options[:spamtrap_salt]
139
+ @spamtrap_options = options[:spamtrap] if options[:spamtrap].is_a?(Hash)
140
+ @spamtrap_timestamp = options[:spamtrap_timestamp] if options[:spamtrap_timestamp]
141
+ @spamtrap_tokens = options[:spamtrap_tokens] if options[:spamtrap_tokens]
142
+ # A fields_for child inherits its parent's already-computed context (may legitimately be nil).
143
+ @spamtrap_context = options[:spamtrap_context] if options.key?(:spamtrap_context)
144
+ # Mint the shared timestamp up front so mutation works no matter where f.spamtrap
145
+ # is called in the form (or if it's never called at all).
146
+ @spamtrap_timestamp ||= Time.now.to_i if @spamtrap_options && @spamtrap_options[:mutate]
76
147
  end
77
148
 
78
149
  def fields_for(record_name, record_object = nil, fields_options = {}, &block)
79
- if @spamtrap_salt
150
+ if @spamtrap_timestamp
151
+ # Children share the parent's token cache: indexless `items[][name]` arrays only split
152
+ # into separate records when the same field name repeats as the same key. spamtrap_context
153
+ # is computed here (not left lazy) so a child never needs its own request.
154
+ shared = { spamtrap_timestamp: @spamtrap_timestamp, spamtrap_tokens: (@spamtrap_tokens ||= {}), spamtrap_context: spamtrap_context }
80
155
  if record_object.is_a?(Hash) && record_object.extractable_options?
81
- record_object = record_object.merge(spamtrap_salt: @spamtrap_salt)
156
+ record_object = record_object.merge(shared)
82
157
  else
83
- fields_options = fields_options.merge(spamtrap_salt: @spamtrap_salt)
158
+ fields_options = fields_options.merge(shared)
84
159
  end
85
160
  end
86
161
  super(record_name, record_object, fields_options, &block)
87
162
  end
163
+
164
+ private
165
+
166
+ # One token per field name per render (shared with fields_for children), or a fresh IV per
167
+ # call would make <label for> and <input id> mismatch and split indexless array records.
168
+ def spamtrap_token_for(field)
169
+ @spamtrap_tokens ||= {}
170
+ @spamtrap_tokens[field.to_s] ||= spamtrap_encrypt_field(field.to_s, spamtrap_aad(@spamtrap_timestamp, spamtrap_context))
171
+ end
172
+
173
+ # Memoized per builder (nil is a legitimate result when Spamtrap.token_context is unset),
174
+ # so every token this builder mints — mutation, nonce, js proof — binds to the same context.
175
+ def spamtrap_context
176
+ return @spamtrap_context if defined?(@spamtrap_context)
177
+
178
+ @spamtrap_context =
179
+ if Spamtrap.token_context
180
+ unless @template.respond_to?(:request) && @template.request
181
+ raise Spamtrap::NoRequestError, 'Spamtrap.token_context needs a request; render the form inside a request or unset Spamtrap.token_context'
182
+ end
183
+ Spamtrap.token_context_for(@template.request)
184
+ end
185
+ end
186
+
187
+ # Fills in the key each helper reads its pre-filled state from (:value, :checked,
188
+ # :selected...), unless the caller already gave one explicitly.
189
+ def spamtrap_apply_model_value(m, target, model_value, tag_value = nil)
190
+ # Some tags (Rails 8.1 date/time fields, time_zone_select, weekday_select) read the
191
+ # model value eagerly via the encrypted method name; this tells Rails to return nil
192
+ # instead of raising. Rails deletes the key, so it never reaches the HTML.
193
+ target[:allow_method_names_outside_object] = true unless target.key?(:allow_method_names_outside_object)
194
+
195
+ case m
196
+ when :check_box
197
+ target[:checked] = !!model_value unless target.key?(:checked)
198
+ when :radio_button
199
+ target[:checked] = (model_value.to_s == tag_value.to_s) unless target.key?(:checked)
200
+ when :select, :collection_select, :grouped_collection_select, :weekday_select
201
+ target[:selected] = model_value unless target.key?(:selected)
202
+ when :time_zone_select
203
+ # Unlike the others, TimeZoneSelect falls back to :default, not :selected.
204
+ target[:default] = model_value unless target.key?(:default)
205
+ when :collection_check_boxes, :collection_radio_buttons
206
+ target[:checked] = model_value unless target.key?(:checked)
207
+ when :date_select, :time_select, :datetime_select
208
+ target[:selected] = model_value unless target.key?(:selected) || target.key?(:default) || model_value.nil?
209
+ else
210
+ target[:value] = model_value unless target.key?(:value)
211
+ end
212
+ end
213
+
214
+ # Mirrors ActionView::Helpers::Tags::Base#sanitized_value so a radio button's id is
215
+ # identical to what an unmutated render would produce, just rooted at the real field name.
216
+ def spamtrap_radio_id(field, tag_value)
217
+ suffix = tag_value.to_s.gsub(/[\s.]/, '_').gsub(/[^-[[:word:]]]/, '').downcase
218
+ field_id(field, suffix)
219
+ end
220
+
221
+ # Rails looks up object.errors[method] using the (encrypted) method name it was given,
222
+ # which never has errors for a mutated field, so field_with_errors silently stops
223
+ # applying. Replicate it here using the real field name instead.
224
+ def spamtrap_error_wrap(html, field)
225
+ return html unless object.respond_to?(:errors) && object.errors[field].present?
226
+
227
+ tag_stub = Struct.new(:object, :method_name, :error_message).new(object, field.to_s, object.errors[field])
228
+ @template.instance_exec(html, tag_stub, &ActionView::Base.field_error_proc)
229
+ end
88
230
  end
89
231
  end
90
232
 
91
- class ActionView::Helpers::FormBuilder
92
- prepend Spamtrap::FormBuilderMutation
93
-
233
+ # The f.spamtrap helper. Mixed into ActionView::Helpers::FormBuilder by Spamtrap.install!
234
+ # (via the Railtie in a Rails app) rather than by reopening the class at require time.
235
+ module Spamtrap::FormBuilderHelper
94
236
  def spamtrap(parameter = 'spamtrap', options = {})
95
- mutate = options.key?(:mutate) ? options.delete(:mutate) : Spamtrap.mutate
96
- nonce = options.key?(:nonce) ? options.delete(:nonce) : Spamtrap.nonce
97
- options.reverse_merge!(class: 'spamtrap')
98
-
99
- if mutate
100
- @spamtrap_salt = SecureRandom.bytes(Spamtrap::Crypto::NONCE_LEN)
101
- salt_field = @template.hidden_field_tag(
102
- :spamtrap_mutation_salt, @spamtrap_salt.unpack1('H*')
103
- )
104
- end
237
+ mutate = options.key?(:mutate) ? options.delete(:mutate) : spamtrap_option_default(:mutate)
238
+ nonce = options.key?(:nonce) ? options.delete(:nonce) : spamtrap_option_default(:nonce)
239
+ bind_ip = options.key?(:nonce_bind_ip) ? options.delete(:nonce_bind_ip) : spamtrap_option_default(:nonce_bind_ip)
240
+ styles = Array(options.key?(:styles) ? options.delete(:styles) : spamtrap_option_default(:styles))
241
+ js_proof = options.key?(:js_proof) ? options.delete(:js_proof) : spamtrap_option_default(:js_proof)
242
+ options.reverse_merge!(class: 'spamtrap', tabindex: -1, autocomplete: 'off', 'aria-hidden' => true, style: 'display:none')
105
243
 
106
- @template.text_area_tag(parameter, nil, options) +
107
- (nonce ? spamtrap_nonce_fields : ''.html_safe) +
108
- (salt_field || ''.html_safe)
244
+ # One timestamp per render, shared by mutation tokens, the nonce HMAC, and js_proof, so the
245
+ # hidden field is emitted once no matter which subset of these features is on. Reuse the
246
+ # timestamp already minted at builder construction (spamtrap: { mutate: true }) instead of a fresh one.
247
+ timestamp = @spamtrap_timestamp || (Time.now.to_i if mutate || nonce || js_proof)
248
+ @spamtrap_timestamp = timestamp if mutate
249
+
250
+ spamtrap_honeypot_tags(parameter, styles, options) +
251
+ ((mutate || nonce || js_proof) ? @template.hidden_field_tag(:spamtrap_timestamp, timestamp) : ''.html_safe) +
252
+ (nonce ? spamtrap_nonce_fields(timestamp, parameter.to_s, bind_ip) : ''.html_safe) +
253
+ (js_proof ? spamtrap_js_tags(timestamp, parameter.to_s) : ''.html_safe)
109
254
  end
110
255
 
111
256
  private
112
257
 
113
- def spamtrap_nonce_fields
114
- timestamp = Time.now.to_i
115
- ip = @template.request.remote_ip
116
- secret = Rails.application.secret_key_base
117
- nonce = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}:#{ip}")
258
+ def spamtrap_option_default(key)
259
+ if @spamtrap_options&.key?(key)
260
+ @spamtrap_options[key]
261
+ else
262
+ # :styles has no same-named Spamtrap global; it maps to honeypot_styles instead.
263
+ Spamtrap.public_send(key == :styles ? :honeypot_styles : key)
264
+ end
265
+ end
266
+
267
+ # One decoy per requested style, all sharing the same hiding options; each decoy's own name
268
+ # is mutated (see spamtrap_token_for) exactly like the classic textarea's is.
269
+ def spamtrap_honeypot_tags(parameter, styles, options)
270
+ field_names = Spamtrap.honeypot_fields(parameter)
271
+
272
+ styles.map do |style|
273
+ raise ArgumentError, "unknown spamtrap honeypot style: #{style.inspect}" unless field_names.key?(style)
274
+
275
+ name = @spamtrap_timestamp ? spamtrap_token_for(field_names[style]) : field_names[style]
118
276
 
119
- @template.hidden_field_tag(:spamtrap_timestamp, timestamp) +
277
+ case style
278
+ when :textarea then @template.text_area_tag(name, nil, options)
279
+ when :text then @template.text_field_tag(name, nil, options)
280
+ when :checkbox then @template.check_box_tag(name, '1', false, options)
281
+ end
282
+ end.inject(:+)
283
+ end
284
+
285
+ def spamtrap_nonce_fields(timestamp, honeypot, bind_ip)
286
+ unless @template.respond_to?(:request) && @template.request
287
+ raise Spamtrap::NoRequestError, 'Spamtrap nonce fields need a request; render the form inside a request or pass nonce: false'
288
+ end
289
+
290
+ ip = @template.request.remote_ip
291
+ nonce_id = SecureRandom.hex(16)
292
+ nonce = spamtrap_nonce_digest(timestamp, ip, honeypot, nonce_id, bind_ip: bind_ip, context: spamtrap_context)
293
+
294
+ @template.hidden_field_tag(:spamtrap_nonce_id, nonce_id) +
120
295
  @template.hidden_field_tag(:spamtrap_nonce, nonce)
121
296
  end
297
+
298
+ # A hidden field only an executing script fills in, plus the inline script that fills it.
299
+ # javascript_tag(nonce: true) attaches a CSP nonce automatically when the app configures one.
300
+ def spamtrap_js_tags(timestamp, honeypot)
301
+ id = "spamtrap_js_#{SecureRandom.hex(4)}"
302
+ digest = spamtrap_js_digest(timestamp, honeypot, context: spamtrap_context)
303
+ # Embedded reversed so a naive regex scrape of the page source won't find it; obfuscation, not security.
304
+ script = %{(function(){var i=document.getElementById("#{id}");if(i){i.value="#{digest.reverse}".split("").reverse().join("");}})();}
305
+
306
+ @template.hidden_field_tag(:spamtrap_js, '', id: id) +
307
+ @template.javascript_tag(script, nonce: true)
308
+ end
122
309
  end
@@ -0,0 +1,13 @@
1
+ module Spamtrap
2
+ class Railtie < Rails::Railtie
3
+ # on_load so the patches land after app initializers, not at require time.
4
+ initializer 'spamtrap.install' do
5
+ ActiveSupport.on_load(:action_controller_base) { Spamtrap.install_controller! }
6
+ ActiveSupport.on_load(:action_controller_api) { Spamtrap.install_controller! }
7
+ ActiveSupport.on_load(:action_view) { Spamtrap.install_form_builder! }
8
+ # A FormBuilder built before any view renders (e.g. in ActionView::TestCase) would
9
+ # miss the on_load hook, so patch now if the form helpers are already loaded.
10
+ Spamtrap.install_form_builder! if Spamtrap.form_builder_loaded?
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,103 @@
1
+ require 'securerandom'
2
+ require 'spamtrap/crypto'
3
+ require 'spamtrap/controller'
4
+
5
+ # Not required by lib/spamtrap.rb: apps opt in with `require 'spamtrap/test_helper'`
6
+ # and `include Spamtrap::TestHelper` in their own test cases.
7
+ module Spamtrap
8
+ module TestHelper
9
+ # Exposes Crypto's private methods so tests can mint/verify tokens without reimplementing them.
10
+ class TokenHelper
11
+ include Spamtrap::Crypto
12
+ public :spamtrap_encrypt_field, :spamtrap_decrypt_field, :spamtrap_nonce_digest, :spamtrap_js_digest, :spamtrap_aad
13
+ end
14
+
15
+ def spamtrap_token(name, timestamp, context: nil)
16
+ helper = TokenHelper.new
17
+ helper.spamtrap_encrypt_field(name.to_s, helper.spamtrap_aad(timestamp, context))
18
+ end
19
+
20
+ def spamtrap_decrypt(token, timestamp, context: nil)
21
+ helper = TokenHelper.new
22
+ helper.spamtrap_decrypt_field(token, helper.spamtrap_aad(timestamp, context))
23
+ end
24
+
25
+ # at defaults 5s in the past so Spamtrap.min_fill_time (default 1s) doesn't trap these tokens.
26
+ def spamtrap_nonce_params(honeypot:, ip:, at: Time.now.to_i - 5, nonce_id: SecureRandom.hex(16), bind_ip: Spamtrap.nonce_bind_ip, context: nil)
27
+ {
28
+ spamtrap_timestamp: at,
29
+ spamtrap_nonce_id: nonce_id,
30
+ spamtrap_nonce: TokenHelper.new.spamtrap_nonce_digest(at, ip, honeypot, nonce_id, bind_ip: bind_ip, context: context)
31
+ }
32
+ end
33
+
34
+ # The value the inline js_proof script would write into spamtrap_js for a page rendered at `at`.
35
+ def spamtrap_js_param(honeypot:, at:, context: nil)
36
+ { spamtrap_js: TokenHelper.new.spamtrap_js_digest(at, honeypot, context: context) }
37
+ end
38
+
39
+ # Builds the params hash an app's own controller test merges into its `post` params:
40
+ # the honeypot field, plus whatever spamtrap_timestamp/nonce/js_proof fields the declared
41
+ # options require, so the request clears the whole gauntlet in one call. `fields:` is
42
+ # merged in as-is, or through spamtrap_mutate first when `mutate:` is truthy.
43
+ # at defaults 5s in the past so Spamtrap.min_fill_time (default 1s) doesn't trap these tokens.
44
+ def spamtrap_params(honeypot:, ip: '0.0.0.0', nonce: false, mutate: false, js_proof: false, at: Time.now.to_i - 5, bind_ip: Spamtrap.nonce_bind_ip, fields: nil, context: nil)
45
+ params = {}
46
+ if nonce
47
+ params.merge!(spamtrap_nonce_params(honeypot: honeypot, ip: ip, at: at, bind_ip: bind_ip, context: context))
48
+ elsif mutate
49
+ params[:spamtrap_timestamp] = at
50
+ end
51
+ if js_proof
52
+ params[:spamtrap_timestamp] ||= at
53
+ params.merge!(spamtrap_js_param(honeypot: honeypot, at: at, context: context))
54
+ end
55
+ params[honeypot] = ''
56
+ params.merge!(mutate ? spamtrap_mutate(fields, at: at, context: context) : fields) if fields
57
+ params
58
+ end
59
+
60
+ # Encrypts every field name in `hash` exactly the way the form builder renders them, so
61
+ # the result round-trips through spamtrap_remap_hash under a strict action. A key whose
62
+ # value is a Hash, or an Array of Hashes, is a container (object/fields_for name) and
63
+ # stays plaintext with its value recursed into; every other key, including one whose
64
+ # value is an Array of scalars, is a field and gets encrypted as a whole. A trailing
65
+ # multi-parameter suffix like "(1i)" is preserved after the token. Top-level keys in
66
+ # FRAMEWORK_PARAMS/CAPTCHA_PARAMS are left alone. Accepts string or symbol keys, returns
67
+ # string keys.
68
+ def spamtrap_mutate(hash, at:, context: nil)
69
+ spamtrap_mutate_hash(hash, at, {}, top_level: true, context: context)
70
+ end
71
+
72
+ private
73
+
74
+ # tokens: one token per field name, as the form builder does, so array records stay separate.
75
+ def spamtrap_mutate_hash(hash, at, tokens, top_level: false, context: nil)
76
+ allowlist = Spamtrap::Controller::FRAMEWORK_PARAMS + Spamtrap::Controller::CAPTCHA_PARAMS
77
+
78
+ hash.each_with_object({}) do |(key, value), memo|
79
+ key_str = key.to_s
80
+
81
+ if top_level && allowlist.include?(key_str)
82
+ memo[key_str] = value
83
+ elsif spamtrap_mutate_container?(value)
84
+ memo[key_str] = spamtrap_mutate_container(value, at, tokens, context: context)
85
+ else
86
+ base, suffix = key_str.match(/\A(.+?)(\(\d+[a-z]\))\z/)&.captures
87
+ name = base || key_str
88
+ memo["#{tokens[name] ||= spamtrap_token(name, at, context: context)}#{suffix}"] = value
89
+ end
90
+ end
91
+ end
92
+
93
+ def spamtrap_mutate_container?(value)
94
+ value.is_a?(Hash) || (value.is_a?(Array) && value.any? { |el| el.is_a?(Hash) })
95
+ end
96
+
97
+ def spamtrap_mutate_container(value, at, tokens, context: nil)
98
+ return spamtrap_mutate_hash(value, at, tokens, context: context) if value.is_a?(Hash)
99
+
100
+ value.map { |el| el.is_a?(Hash) ? spamtrap_mutate_hash(el, at, tokens, context: context) : el }
101
+ end
102
+ end
103
+ end
@@ -1,5 +1,5 @@
1
1
  module Spamtrap
2
2
 
3
- VERSION = '0.3.4'
3
+ VERSION = '0.4.2'
4
4
 
5
5
  end