cama_contact_form 0.1.12 → 0.1.14

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3853b9d6da4e632d947929a801497c0ea6daa25eff3b08834adf1c1aa5d5b86
4
- data.tar.gz: 2293350d6071301a7795322485e25402974f82ef2dfadd485833a3c7bf89c851
3
+ metadata.gz: b1df5cd27281765a9aa2642b6442a5613db070ac0777c32e0726e8efb8f05d48
4
+ data.tar.gz: '09312e91b0deb83c6c3508e8d84228deafbc8ef8ea8f45581a66e0ef36ea8cd6'
5
5
  SHA512:
6
- metadata.gz: d6aff50b5f8576691862b332848774945591b00600bae4f0c36bd69852ce15dc90c92f2869b1a21a4d6b2e6bab2a6d623b2c1cd5ef93f70ce7265314d8c96112
7
- data.tar.gz: e4f5b5977c96e550e3960d07b2fd4c5ae84ed2dae74c4e21f71036815f447476557580ba59c85738bd74f479a4580931d14f67688bfe47067f12d0d83571dcc4
6
+ metadata.gz: 2d129553fbafaa0ed0e647c27985ae4ccf426ca3cded590f895b965cc20d69dc488b2edf1a4a372411cb23896bfc1a9ef6ae5ae707935ab28a39fc57e08052c0
7
+ data.tar.gz: 5c35ee94f4eec03c48e273326aa57884fa185c2a933886bde25bbbbeda97de4b4d12338a388afdb10b37049d9f2ae91c02ae343044be83cd714b97ad1343f7d4
data/README.md CHANGED
@@ -15,8 +15,7 @@ release needs, in this order:
15
15
  5. publishes the GitHub release, with notes taken from `CHANGELOG.md` and the `.gem` plus its
16
16
  checksums attached.
17
17
 
18
- No tests run here — the suite that covers this plugin lives in the
19
- [camaleon_cms](https://github.com/owen2345/camaleon-cms) repository.
18
+ No tests run here — the release workflow only builds and publishes the gem.
20
19
 
21
20
  `lib/cama_contact_form/version.rb` is the single source of truth for the version. The tag, the
22
21
  published gem and the GitHub release all derive from it, so they cannot disagree.
@@ -46,7 +45,7 @@ Edit `lib/cama_contact_form/version.rb`:
46
45
 
47
46
  ```ruby
48
47
  module CamaContactForm
49
- VERSION = "0.1.12"
48
+ VERSION = '0.1.12'
50
49
  end
51
50
  ```
52
51
 
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  begin
2
4
  require 'bundler/setup'
3
5
  rescue LoadError
@@ -14,24 +16,7 @@ RDoc::Task.new(:rdoc) do |rdoc|
14
16
  rdoc.rdoc_files.include('lib/**/*.rb')
15
17
  end
16
18
 
17
- APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__)
18
- load 'rails/tasks/engine.rake'
19
-
20
-
21
- load 'rails/tasks/statistics.rake'
22
-
23
-
24
-
19
+ # The plugin's behaviour is exercised host-side in the camaleon_cms repository (see README /
20
+ # CHANGELOG); this repo ships no test suite, so the dummy-app engine tasks and the default `test`
21
+ # task are intentionally absent. `Bundler::GemHelper.install_tasks` still provides build/install/release.
25
22
  Bundler::GemHelper.install_tasks
26
-
27
- require 'rake/testtask'
28
-
29
- Rake::TestTask.new(:test) do |t|
30
- t.libs << 'lib'
31
- t.libs << 'test'
32
- t.pattern = 'test/**/*_test.rb'
33
- t.verbose = false
34
- end
35
-
36
-
37
- task default: :test
@@ -1,3 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ # Contact-form logic shared by the admin and front controllers: it gates authored markup at save
6
+ # time, and validates, stores and mails a visitor's submission.
1
7
  module Plugins::CamaContactForm::ContactFormControllerConcern
2
8
  # The field types whose submitted value the renderer interpolates back into the page, and the
3
9
  # position each one lands in. Everything else -- radio, checkboxes, dropdown, file -- is only ever
@@ -5,55 +11,191 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
5
11
  ECHOED_ATTRIBUTE_FIELD_TYPES = %w[text website email].freeze
6
12
  ECHOED_TEXTAREA_FIELD_TYPES = %w[paragraph textarea].freeze
7
13
 
8
- # Elements that do something rather than say something, wherever they appear.
9
- ACTIVE_ELEMENTS = %w[script style iframe object embed applet frame frameset form input button
14
+ # Field types whose submitted value is a list of chosen option labels, joined for the mail summary.
15
+ MULTI_VALUE_FIELD_TYPES = %w[radio checkboxes].freeze
16
+
17
+ # Elements that do something rather than say something, wherever they appear. `img` sits with the
18
+ # other external-resource loaders (`iframe`, `object`, `embed`): the notification e-mail renders a
19
+ # visitor's value with `raw`, so `<img src="http://attacker/">` there is a tracking beacon that
20
+ # fetches an attacker URL -- leaking that the owner opened the mail, and their IP -- the moment
21
+ # they open it, which no contact message legitimately needs. Ordinary prose with angle brackets
22
+ # (`Fish & Chips <today>`) is an unknown tag, not one of these, so it still passes.
23
+ ACTIVE_ELEMENTS = %w[script style iframe object embed img applet frame frameset form input button
10
24
  link meta base svg math template].freeze
11
- ACTIVE_ELEMENT = /<\s*\/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b/i
25
+ ACTIVE_ELEMENT = %r{<\s*/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b}i
12
26
  EVENT_HANDLER_IN_TAG = /<[a-zA-Z][^>]*\son[a-zA-Z]+\s*=/im
13
27
  URL_SCHEME_IN_TAG =
14
- %r{<[a-zA-Z][^>]*\b(?:href|src|action|formaction|data|poster|srcdoc|background)\s*=\s*
15
- ["']?\s*(?:javascript|vbscript|data)\s*:}imx
28
+ /<[a-zA-Z][^>]*\b(?:href|src|action|formaction|data|poster|srcdoc|background)\s*=\s*
29
+ ["']?\s*(?:javascript|vbscript|data)\s*:/imx
30
+
31
+ # How many submissions one client IP may make to one form before the excess is refused, and the
32
+ # window that count rolls off over. The threshold is the site-wide `contact_form_max_submits` option
33
+ # (it tunes every form on the site at once, not one form); the window is camaleon_cms's own login
34
+ # throttle window, shared as one constant so the two cannot drift.
35
+ SUBMISSION_THROTTLE_WINDOW = CamaleonCms::CaptchaHelper::CAMA_ATTACK_WINDOW
36
+ SUBMISSION_THROTTLE_DEFAULT_MAX = 10
37
+
38
+ # How many files one submission may attach across all of its file fields, before the whole
39
+ # submission is refused. Core bounds each file's size (`filesystem_max_size`); this bounds the
40
+ # count, so the two together bound what one anonymous POST can write to disk and stuff into the
41
+ # notification mail. The site-wide `contact_form_max_files` option overrides it.
42
+ ATTACHMENT_COUNT_DEFAULT_MAX = 5
16
43
 
17
44
  def perform_save_form(form, fields, success, errors)
18
45
  attachments = []
19
- if validate_to_save_form(form, fields, errors)
20
- form.fields.each do |f|
21
- if f[:field_type] == 'file'
22
- file_paths = []
23
- fields[f[:cid].to_sym].to_a.each do |file|
24
- res = cama_tmp_upload(file, {
25
- maximum: current_site.get_option('filesystem_max_size', 100).megabytes,
26
- path: Rails.public_path.join("contact_form", current_site.id.to_s),
27
- name: file.original_filename
28
- }
29
- )
30
- if res[:error].present?
31
- errors << res[:error].to_s.translate
32
- else
33
- attachments << res[:file_path]
34
- file_paths << res[:file_path].sub(Rails.public_path.to_s, cama_root_url)
35
- end
36
- end
37
- fields[f[:cid].to_sym] = file_paths
46
+ return unless validate_to_save_form(form, fields, errors)
47
+
48
+ form.fields.each do |f|
49
+ next unless f[:field_type] == 'file'
50
+
51
+ file_paths = []
52
+ fields[f[:cid].to_sym].to_a.each do |file|
53
+ res = cama_tmp_upload(file, {
54
+ maximum: current_site.get_option('filesystem_max_size', 100).megabytes,
55
+ path: Rails.public_path.join('contact_form', current_site.id.to_s),
56
+ name: file.original_filename
57
+ })
58
+ if res[:error].present?
59
+ errors << res[:error].to_s.translate
60
+ else
61
+ attachments << res[:file_path]
62
+ file_paths << res[:file_path].sub(Rails.public_path.to_s, cama_root_url)
38
63
  end
39
64
  end
40
- new_settings = {"fields" => fields, "created_at" => Time.current.strftime("%Y-%m-%d %H:%M:%S").to_s}.to_json
41
- form_new = current_site.contact_forms.new(name: "response-#{Time.now}", description: form.description, settings: new_settings, site_id: form.site_id, parent_id: form.id)
42
- if form_new.save
43
- fields_data = convert_form_values(form, fields)
44
- message_body = form.mail_settings[:body].to_s.translate.cama_replace_codes(fields)
45
- content = render_to_string(partial: plugin_view('contact_form/email_content'), layout: false, formats: [:html], locals: {file_attachments: attachments, fields: fields_data, values: fields, message_body: message_body, form: form})
46
- cama_send_email(form.mail_settings[:to], form.mail_settings[:subject].to_s.translate.cama_replace_codes(fields), {attachments: attachments, content: content, extra_data: {fields: fields_data}})
47
- success << form.the_message('mail_sent_ok', t('.success_form_val', default: 'Your message has been sent successfully. Thank you very much!'))
48
- args = {form: form, values: fields}; hooks_run("contact_form_after_submit", args)
49
- if form.mail_settings[:to_answer].present? && (answer_to = fields[form.mail_settings[:to_answer].to_s.gsub(/(\[|\])/, '').to_sym]).present?
50
- content = form.mail_settings[:body_answer].to_s.translate.cama_replace_codes(fields)
51
- cama_send_email(answer_to, form.mail_settings[:subject_answer].to_s.translate.cama_replace_codes(fields), {content: content})
52
- end
53
- else
54
- errors << form.the_message('mail_sent_ng', t('.error_form_val', default: 'An error occurred, please try again.'))
65
+ fields[f[:cid].to_sym] = file_paths
66
+ end
67
+ new_settings = { 'fields' => fields, 'created_at' => Time.now.utc.strftime('%Y-%m-%d %H:%M:%S').to_s }.to_json
68
+ # The random suffix keeps concurrent responses distinct: stamped only to the second, two visitors
69
+ # submitting within the same second parameterized to the same slug, and the site-scoped
70
+ # slug-uniqueness validation refused the second response with the generic error.
71
+ form_new = current_site.contact_forms.new(name: "response-#{Time.now.utc}-#{SecureRandom.hex(4)}",
72
+ description: form.description,
73
+ settings: new_settings, site_id: form.site_id, parent_id: form.id)
74
+ if form_new.save
75
+ record_submission(form)
76
+ fields_data = convert_form_values(form, fields)
77
+ message_body = form.mail_settings[:body].to_s.translate.cama_replace_codes(fields)
78
+ content = render_to_string(partial: plugin_view('contact_form/email_content'), layout: false, formats: [:html],
79
+ locals: { file_attachments: attachments, fields: fields_data, values: fields,
80
+ message_body: message_body, form: form })
81
+ cama_send_email(form.mail_settings[:to],
82
+ form.mail_settings[:subject].to_s.translate.cama_replace_codes(fields),
83
+ { attachments: attachments, content: content, extra_data: { fields: fields_data } })
84
+ success << form.the_message('mail_sent_ok',
85
+ t('.success_form_val',
86
+ default: 'Your message has been sent successfully. Thank you very much!'))
87
+ args = { form: form, values: fields }
88
+ hooks_run('contact_form_after_submit', args)
89
+ if (answer_to = auto_reply_recipient(form, fields))
90
+ content = form.mail_settings[:body_answer].to_s.translate.cama_replace_codes(fields)
91
+ cama_send_email(answer_to, form.mail_settings[:subject_answer].to_s.translate.cama_replace_codes(fields),
92
+ { content: content })
55
93
  end
94
+ else
95
+ errors << form.the_message('mail_sent_ng',
96
+ t('.error_form_val', default: 'An error occurred, please try again.'))
97
+ end
98
+ end
99
+
100
+ # The auto-reply ("confirmation e-mail") recipient is whatever the visitor typed into the field
101
+ # named by `to_answer`, so it is fully attacker-controlled -- unchecked, the feature sends mail from
102
+ # the site's own From address to anyone. The reply goes to the normalized address, or nowhere.
103
+ # Volume across submissions is a separate concern (rate limiting, below).
104
+ #
105
+ # A refused present value is logged -- otherwise the response is indistinguishable from full
106
+ # success and a lost confirmation is undiagnosable. The line names the form, not the value: the
107
+ # value is hostile by hypothesis, and hostile bytes don't belong in the log stream. An absent or
108
+ # blank value stays silent, as it always has -- the visitor supplied nothing to refuse.
109
+ def auto_reply_recipient(form, fields)
110
+ return if form.mail_settings[:to_answer].blank?
111
+
112
+ value = fields[form.mail_settings[:to_answer].to_s.gsub(/(\[|\])/, '').to_sym]
113
+ address = normalized_email_address(value)
114
+ if address.nil? && value.present?
115
+ Rails.logger.warn("cama_contact_form: auto-reply for form #{form.id} skipped, recipient failed validation")
56
116
  end
117
+ address
118
+ end
119
+
120
+ # The stripped value when it is a single, syntactically-valid address; nil otherwise. Surrounding
121
+ # whitespace is stripped -- a pasted or mobile-typed address often carries a stray space, and the
122
+ # mailer delivered those before this guard existed -- and what remains must match
123
+ # `URI::MailTo::EMAIL_REGEXP`, which is anchored and admits no CR/LF, inner whitespace or `,`/`;`,
124
+ # so one submission can neither header-inject a Bcc nor fan out to a recipient list. Refused with
125
+ # the attacks, deliberately: name-addr forms (`Jane <jane@example.com>` -- address lists share that
126
+ # grammar) and raw-unicode addresses (the regexp is ASCII-only; the punycode form passes). A
127
+ # non-String value (`fields[cid][]=…` arrives as an Array) is rejected through `to_s` rather than
128
+ # raising.
129
+ def normalized_email_address(value)
130
+ address = value.to_s.strip
131
+ address if address.match?(URI::MailTo::EMAIL_REGEXP)
132
+ end
133
+
134
+ # Whether this client IP has already used up its budget for this form in the current window, so
135
+ # `save_form` can refuse the excess before any mail, upload or row is written -- the endpoint is
136
+ # public and, unless the form carries a captcha field, otherwise unthrottled. A read only: only a
137
+ # *stored* submission spends budget (see `record_submission`), so a flood of submissions that fail
138
+ # validation -- or that never solve a captcha -- cannot exhaust the window and lock out a co-NAT
139
+ # visitor whose own submission is valid.
140
+ def submission_over_limit?(form)
141
+ Rails.cache.read(submission_throttle_key(form), raw: true).to_i >= submission_limit
142
+ end
143
+
144
+ # Spend one unit of this IP's per-form budget, called once a row has actually been written so the
145
+ # cap tracks resource-consuming submissions rather than mere attempts.
146
+ #
147
+ # The counter is an ATOMIC Rails.cache increment mirroring camaleon_cms's login throttle. Its helper
148
+ # (cama_captcha_increment_attack) is deliberately not reused: it also mutates session state, which
149
+ # this IP-only throttle must not do on a public, session-light request (and its unit spec drives a
150
+ # bare controller with no session). `raw: true` keeps the value a bare integer so Redis/Memcached
151
+ # INCR is atomic (a harmless no-op on Memory/File stores). Older Memory/File stores (Rails < 7.1)
152
+ # return nil for a missing key instead of seeding it; seed it then, with `unless_exist` so the seed
153
+ # can never overwrite a counter a concurrent request has already advanced.
154
+ #
155
+ # The window is FIXED, not sliding: the TTL is anchored when the key is first written and not
156
+ # refreshed on later increments (ActiveSupport preserves the original `expires_at`, and
157
+ # Redis/Memcached only set a TTL at creation), so the budget resets in full once the window since
158
+ # the first stored submission elapses. The throttle is only as strong as the host's cache: it fails
159
+ # open on a null store, and on a per-process store (Rails' default MemoryStore, or FileStore across
160
+ # hosts) each worker counts on its own, so an effective cap needs a shared store (Redis/Memcached).
161
+ # The key is the client IP as camaleon_cms resolves it (`request.remote_ip`), so it is only as
162
+ # trustworthy as the app's trusted-proxy configuration.
163
+ def record_submission(form)
164
+ key = submission_throttle_key(form)
165
+ counted = Rails.cache.increment(key, 1, expires_in: SUBMISSION_THROTTLE_WINDOW, raw: true)
166
+ return unless counted.nil?
167
+
168
+ Rails.cache.write(key, 1, expires_in: SUBMISSION_THROTTLE_WINDOW, unless_exist: true, raw: true)
169
+ end
170
+
171
+ def submission_throttle_key(form)
172
+ "cama_contact_form_submit:#{current_site.id}:#{request.remote_ip}:#{form.id}"
173
+ end
174
+
175
+ # The per-window budget from `contact_form_max_submits`, as a positive integer.
176
+ def submission_limit
177
+ positive_site_option('contact_form_max_submits', SUBMISSION_THROTTLE_DEFAULT_MAX)
178
+ end
179
+
180
+ # The per-submission attachment budget from `contact_form_max_files`, as a positive integer.
181
+ def attachment_limit
182
+ positive_site_option('contact_form_max_files', ATTACHMENT_COUNT_DEFAULT_MAX)
183
+ end
184
+
185
+ # A limit option, as a positive integer. get_option hands back whatever was stored, and
186
+ # camaleon_cms's set_option runs values through String#to_var -- so a "true"/"false" option is a
187
+ # boolean (and `false.to_i` raises), a cleared one is nil, and a stray "unlimited"/0 would coerce
188
+ # to 0 and refuse every submission (or every attachment) site-wide. Anything that is not a
189
+ # positive integer falls back to the default rather than 500-ing or silently bricking the form; to
190
+ # loosen a limit an operator sets a higher positive integer.
191
+ #
192
+ # A String is parsed in base 10 explicitly: to_var stores only canonical numerals as numbers, so
193
+ # a typed "010" survives as a String, and bare Integer() would read its leading zero as octal --
194
+ # a silently wrong limit. Base 10 reads it as ten, and rejects "0x10" into the fallback.
195
+ def positive_site_option(name, default)
196
+ value = current_site.get_option(name, default)
197
+ parsed = value.is_a?(String) ? Integer(value, 10, exception: false) : Integer(value, exception: false)
198
+ parsed&.positive? ? parsed : default
57
199
  end
58
200
 
59
201
  # A visitor's submission is rejected, not escaped, for the same reason an untrusted author's is:
@@ -133,16 +275,19 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
133
275
 
134
276
  # form validations
135
277
  def validate_to_save_form(form, fields, errors)
136
- # Refuse outright and stop, before any other validation runs.
137
- #
138
- # Nothing is stored either way, so there is nothing to gain by continuing -- and continuing means
139
- # running the rest of this method over input already known to be hostile, including a reCAPTCHA
140
- # round-trip to an external service.
278
+ # One refusal, outright and first, for every shape no real form produces -- a missing form, a
279
+ # non-hash fields, a forged file-field value. Each is a forged request, not a validation error
280
+ # the visitor can act on; nothing is stored either way, so there is nothing to gain by
281
+ # continuing -- and continuing means running the rest of this method over input already known
282
+ # to be hostile, including a reCAPTCHA round-trip to an external service. Rejecting the file
283
+ # shapes is also what keeps forged entries away from the uploader (see
284
+ # malformed_file_submission?, whose walk the earlier disjuncts' short-circuit vouches for).
141
285
  #
142
286
  # The message names no field and quotes nothing back. The frontend flash partial renders with
143
287
  # `raw`, so echoing the refused value there would make the refusal itself an injection sink --
144
288
  # the same trap as the admin path.
145
- if form.blank? || !(fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters))
289
+ if form.blank? || !(fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters)) ||
290
+ malformed_file_submission?(form, fields)
146
291
  errors << t('.invalid_request_val', default: 'That form could not be submitted. Please try again.')
147
292
  return false
148
293
  end
@@ -157,62 +302,122 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
157
302
 
158
303
  validate = true
159
304
 
305
+ # perform_save_form's file loop uploads and persists every entry submitted under a file field,
306
+ # so the entry count is budget the visitor spends -- bounded here, before any upload runs,
307
+ # rather than left to multiply against the per-file size cap. Over the cap the submission is
308
+ # refused whole, with the other validation errors: trimming to the first N would silently drop
309
+ # files the visitor believes they sent, and the file input is `multiple` with no client-side
310
+ # cap, so a legitimate visitor can hit this and needs the actionable message.
311
+ if attachment_count(form, fields) > (max_files = attachment_limit)
312
+ # The %{max} substitution runs on the resolved message so an author-customized
313
+ # `invalid_files_count` can carry the limit too -- `the_message` returns a custom message
314
+ # verbatim, while the i18n default has already interpolated and is left untouched.
315
+ errors << form.the_message('invalid_files_count',
316
+ t('.too_many_files_val',
317
+ max: max_files,
318
+ default: 'Too many files attached (maximum %{max}). ' \
319
+ 'Please remove some files and try again.'))
320
+ .to_s.gsub('%{max}', max_files.to_s)
321
+ validate = false
322
+ end
323
+
160
324
  form.fields.each do |f|
161
325
  cid = f[:cid].to_sym
162
326
  label = f[:label].to_sym
163
327
  case f[:field_type].to_s
164
- when 'text', 'website', 'paragraph', 'textarea', 'email', 'radio', 'checkboxes', 'dropdown', 'file'
165
- if f[:required].to_s.cama_true? && !fields[cid].present?
166
- errors << "#{label.to_s.translate}: #{form.the_message('invalid_required', t('.error_validation_val', default: 'This value is required'))}"
167
- validate = false
168
- end
169
- # `to_s` because the submitter chooses whether to send the key at all, and what shape to
170
- # send it in: `nil.match` and `Hash#match` are both NoMethodError, on a public endpoint.
171
- if f[:field_type].to_s == 'email'
172
- unless fields[cid].to_s.match(/@/)
173
- errors << "#{label.to_s.translate}: #{form.the_message('invalid_email', t('.email_invalid_val', default: 'The e-mail address appears invalid'))}"
174
- validate = false
175
- end
176
- end
177
- when 'captcha'
178
- error_message = ->{
179
- errors << "#{label.to_s.translate}: #{form.the_message('captcha_not_match', t('.captch_error_val', default: 'The entered code is incorrect'))}"
180
- validate = false
181
- }
182
-
183
- if form.recaptcha_enabled?
184
- form.set_captcha_settings!
185
- error_message.call unless verify_recaptcha
186
- else
187
- error_message.call unless cama_captcha_verified?
188
- end
328
+ when 'text', 'website', 'paragraph', 'textarea', 'email', 'radio', 'checkboxes', 'dropdown', 'file'
329
+ if f[:required].to_s.cama_true? && fields[cid].blank?
330
+ errors << "#{label.to_s.translate}: #{form.the_message('invalid_required',
331
+ t('.error_validation_val',
332
+ default: 'This value is required'))}"
333
+ validate = false
334
+ end
335
+ # Judged by the same rule as the auto-reply recipient (`normalized_email_address`, which is
336
+ # total on any submitted shape -- the submitter chooses whether to send the key at all, and
337
+ # in what shape), so the field validation and the send decision cannot disagree: a malformed
338
+ # address is an error the visitor can act on here, not a success message followed by a
339
+ # confirmation that silently never arrives.
340
+ if (f[:field_type].to_s == 'email') && normalized_email_address(fields[cid]).nil?
341
+ errors << "#{label.to_s.translate}: #{form.the_message('invalid_email',
342
+ t('.email_invalid_val',
343
+ default: 'The e-mail address appears invalid'))}"
344
+ validate = false
345
+ end
346
+ when 'captcha'
347
+ error_message = lambda {
348
+ errors << "#{label.to_s.translate}: #{form.the_message('captcha_not_match',
349
+ t('.captch_error_val',
350
+ default: 'The entered code is incorrect'))}"
351
+ validate = false
352
+ }
353
+
354
+ if form.recaptcha_enabled?
355
+ form.set_captcha_settings!
356
+ error_message.call unless verify_recaptcha
357
+ else
358
+ error_message.call unless cama_captcha_verified?
359
+ end
189
360
  end
190
361
  end
191
362
  validate
192
363
  end
193
364
 
365
+ # Whether any file field carries a value no real form submission produces. The renderer encodes a
366
+ # file field as `fields[cid][]` file parts, and Rack drops an empty-filename part, so the only
367
+ # legitimate shapes are an absent value -- nil, literally, which is why the skip below tests
368
+ # exactly that and not `blank?`: a blank-but-present value (`fields[cid]=`, a JSON `false`) is as
369
+ # forged as any other scalar, and the upload loop's `.to_a` raises on it all the same -- and an
370
+ # array of uploaded files. A bare string, a nested hash, a non-file entry -- each previously
371
+ # raised in the upload loop (`String#to_a`, `original_filename`), an unauthenticated 500.
372
+ #
373
+ # Refusing the whole submission, rather than skipping the forged entries, is load-bearing: a
374
+ # String that survived to cama_tmp_upload would be treated there as a URL to download or a local
375
+ # path to copy, and an anonymous visitor must never steer that.
376
+ def malformed_file_submission?(form, fields)
377
+ form.fields.any? do |f|
378
+ next false unless f[:field_type] == 'file'
379
+
380
+ value = fields[f[:cid].to_sym]
381
+ next false if value.nil?
382
+
383
+ !value.is_a?(Array) || !value.all?(ActionDispatch::Http::UploadedFile)
384
+ end
385
+ end
386
+
387
+ # The number of entries perform_save_form's upload loop would iterate for this submission:
388
+ # everything submitted under a file field. The shape gate has already refused anything that is
389
+ # not an absent value or an array of uploaded files, so this is those arrays' sizes summed --
390
+ # `Array()` keeps the sum total without a shape judgment of its own, the same counting
391
+ # convert_form_values uses.
392
+ def attachment_count(form, fields)
393
+ form.fields.sum do |f|
394
+ f[:field_type] == 'file' ? Array(fields[f[:cid].to_sym]).size : 0
395
+ end
396
+ end
397
+
194
398
  # form values with labels + values to save
195
399
  def convert_form_values(form, fields)
196
400
  values = {}
197
401
  form.fields.each do |field|
198
402
  next unless relevant_field?(field)
403
+
199
404
  ft = field[:field_type]
200
405
  cid = field[:cid].to_sym
201
- label = values.keys.include?(field[:label]) ? "#{field[:label]} (#{cid})" : field[:label].to_s.translate
406
+ label = values.key?(field[:label]) ? "#{field[:label]} (#{cid})" : field[:label].to_s.translate
202
407
  values[label] = []
203
408
  if ft == 'file'
204
409
  nr_files = Array(fields[cid]).size
205
- values[label] << "#{nr_files} #{"file".pluralize(nr_files)} (attached)" if fields[cid].present?
206
- elsif ft == 'radio' || ft == 'checkboxes'
410
+ values[label] << "#{nr_files} #{'file'.pluralize(nr_files)} (attached)" if fields[cid].present?
411
+ elsif MULTI_VALUE_FIELD_TYPES.include?(ft)
207
412
  values[label] << Array(fields[cid]).map { |f| f.to_s.translate }.join(', ') if fields[cid].present?
208
- else
209
- values[label] << fields[cid] if fields[cid].present?
413
+ elsif fields[cid].present?
414
+ values[label] << fields[cid]
210
415
  end
211
416
  end
212
417
  values
213
418
  end
214
419
 
215
420
  def relevant_field?(field)
216
- !%w(captcha submit button).include? field[:field_type]
421
+ %w[captcha submit button].exclude?(field[:field_type])
217
422
  end
218
423
  end