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 +4 -4
- data/README.md +2 -3
- data/Rakefile +5 -20
- data/app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb +281 -76
- data/app/controllers/plugins/cama_contact_form/admin_forms_controller.rb +150 -220
- data/app/controllers/plugins/cama_contact_form/front_controller.rb +39 -6
- data/app/helpers/plugins/cama_contact_form/main_helper.rb +145 -108
- data/app/models/plugins/cama_contact_form/cama_contact_form.rb +51 -28
- data/config/initializers/custom_models.rb +4 -1
- data/config/locales/es.yml +3 -1
- data/config/locales/zh-CN.yml +2 -0
- data/config/routes.rb +18 -17
- data/lib/cama_contact_form/core_compatibility.rb +34 -0
- data/lib/cama_contact_form/engine.rb +22 -0
- data/lib/cama_contact_form/version.rb +3 -1
- data/lib/cama_contact_form.rb +5 -5
- data/lib/tasks/cama_contact_form_tasks.rake +2 -0
- metadata +3 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1df5cd27281765a9aa2642b6442a5613db070ac0777c32e0726e8efb8f05d48
|
|
4
|
+
data.tar.gz: '09312e91b0deb83c6c3508e8d84228deafbc8ef8ea8f45581a66e0ef36ea8cd6'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
#
|
|
9
|
-
|
|
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 =
|
|
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
|
-
|
|
15
|
-
["']?\s*(?:javascript|vbscript|data)\s
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
# running the rest of this method over input already known
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
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} #{
|
|
206
|
-
elsif ft
|
|
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
|
-
|
|
209
|
-
values[label] << fields[cid]
|
|
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
|
-
|
|
421
|
+
%w[captcha submit button].exclude?(field[:field_type])
|
|
217
422
|
end
|
|
218
423
|
end
|