cama_contact_form 0.1.12 → 0.1.13

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: 0773dea2d0ad03b4c0179fc634372ab5fe9a6111da16fe0c187118475df052f6
4
+ data.tar.gz: 7868caf0afb73a4f508855521a1c4a15660a7786dbea9876303096402f198844
5
5
  SHA512:
6
- metadata.gz: d6aff50b5f8576691862b332848774945591b00600bae4f0c36bd69852ce15dc90c92f2869b1a21a4d6b2e6bab2a6d623b2c1cd5ef93f70ce7265314d8c96112
7
- data.tar.gz: e4f5b5977c96e550e3960d07b2fd4c5ae84ed2dae74c4e21f71036815f447476557580ba59c85738bd74f479a4580931d14f67688bfe47067f12d0d83571dcc4
6
+ metadata.gz: 9435100f0afd723a911aa6efee2fc5ed30610b7eea1a5f640eeb45d6e5ff92a7199d4360d599b0ebffcfc279e79fb17dff80135fb4db69e9d88233d356001ea8
7
+ data.tar.gz: 563ba96b2539b554971caee757e47ed840ac48918c7564993197095876d3435b74ac86974023caead09ebcb6742f979f86d1ab58e651d87f7b47e134a5036c66
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,57 +11,103 @@ 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
 
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
+
8
17
  # Elements that do something rather than say something, wherever they appear.
9
18
  ACTIVE_ELEMENTS = %w[script style iframe object embed applet frame frameset form input button
10
19
  link meta base svg math template].freeze
11
- ACTIVE_ELEMENT = /<\s*\/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b/i
20
+ ACTIVE_ELEMENT = %r{<\s*/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b}i
12
21
  EVENT_HANDLER_IN_TAG = /<[a-zA-Z][^>]*\son[a-zA-Z]+\s*=/im
13
22
  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
23
+ /<[a-zA-Z][^>]*\b(?:href|src|action|formaction|data|poster|srcdoc|background)\s*=\s*
24
+ ["']?\s*(?:javascript|vbscript|data)\s*:/imx
16
25
 
17
26
  def perform_save_form(form, fields, success, errors)
18
27
  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
28
+ return unless validate_to_save_form(form, fields, errors)
29
+
30
+ form.fields.each do |f|
31
+ next unless f[:field_type] == 'file'
32
+
33
+ file_paths = []
34
+ fields[f[:cid].to_sym].to_a.each do |file|
35
+ res = cama_tmp_upload(file, {
36
+ maximum: current_site.get_option('filesystem_max_size', 100).megabytes,
37
+ path: Rails.public_path.join('contact_form', current_site.id.to_s),
38
+ name: file.original_filename
39
+ })
40
+ if res[:error].present?
41
+ errors << res[:error].to_s.translate
42
+ else
43
+ attachments << res[:file_path]
44
+ file_paths << res[:file_path].sub(Rails.public_path.to_s, cama_root_url)
38
45
  end
39
46
  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.'))
47
+ fields[f[:cid].to_sym] = file_paths
48
+ end
49
+ new_settings = { 'fields' => fields, 'created_at' => Time.now.utc.strftime('%Y-%m-%d %H:%M:%S').to_s }.to_json
50
+ form_new = current_site.contact_forms.new(name: "response-#{Time.now.utc}", description: form.description,
51
+ settings: new_settings, site_id: form.site_id, parent_id: form.id)
52
+ if form_new.save
53
+ fields_data = convert_form_values(form, fields)
54
+ message_body = form.mail_settings[:body].to_s.translate.cama_replace_codes(fields)
55
+ content = render_to_string(partial: plugin_view('contact_form/email_content'), layout: false, formats: [:html],
56
+ locals: { file_attachments: attachments, fields: fields_data, values: fields,
57
+ message_body: message_body, form: form })
58
+ cama_send_email(form.mail_settings[:to],
59
+ form.mail_settings[:subject].to_s.translate.cama_replace_codes(fields),
60
+ { attachments: attachments, content: content, extra_data: { fields: fields_data } })
61
+ success << form.the_message('mail_sent_ok',
62
+ t('.success_form_val',
63
+ default: 'Your message has been sent successfully. Thank you very much!'))
64
+ args = { form: form, values: fields }
65
+ hooks_run('contact_form_after_submit', args)
66
+ if (answer_to = auto_reply_recipient(form, fields))
67
+ content = form.mail_settings[:body_answer].to_s.translate.cama_replace_codes(fields)
68
+ cama_send_email(answer_to, form.mail_settings[:subject_answer].to_s.translate.cama_replace_codes(fields),
69
+ { content: content })
55
70
  end
71
+ else
72
+ errors << form.the_message('mail_sent_ng',
73
+ t('.error_form_val', default: 'An error occurred, please try again.'))
56
74
  end
57
75
  end
58
76
 
77
+ # CF-1: the auto-reply ("confirmation e-mail") recipient is whatever the visitor typed into the field
78
+ # named by `to_answer`, so it is fully attacker-controlled -- unchecked, the feature sends mail from
79
+ # the site's own From address to anyone. The reply goes to the normalized address, or nowhere.
80
+ # Volume across submissions is a separate concern (CF-2, rate limiting).
81
+ #
82
+ # A refused present value is logged -- otherwise the response is indistinguishable from full
83
+ # success and a lost confirmation is undiagnosable. The line names the form, not the value: the
84
+ # value is hostile by hypothesis, and hostile bytes don't belong in the log stream. An absent or
85
+ # blank value stays silent, as it always has -- the visitor supplied nothing to refuse.
86
+ def auto_reply_recipient(form, fields)
87
+ return if form.mail_settings[:to_answer].blank?
88
+
89
+ value = fields[form.mail_settings[:to_answer].to_s.gsub(/(\[|\])/, '').to_sym]
90
+ address = normalized_email_address(value)
91
+ if address.nil? && value.present?
92
+ Rails.logger.warn("cama_contact_form: auto-reply for form #{form.id} skipped, recipient failed validation (CF-1)")
93
+ end
94
+ address
95
+ end
96
+
97
+ # The stripped value when it is a single, syntactically-valid address; nil otherwise. Surrounding
98
+ # whitespace is stripped -- a pasted or mobile-typed address often carries a stray space, and the
99
+ # mailer delivered those before this guard existed -- and what remains must match
100
+ # `URI::MailTo::EMAIL_REGEXP`, which is anchored and admits no CR/LF, inner whitespace or `,`/`;`,
101
+ # so one submission can neither header-inject a Bcc nor fan out to a recipient list. Refused with
102
+ # the attacks, deliberately: name-addr forms (`Jane <jane@example.com>` -- address lists share that
103
+ # grammar) and raw-unicode addresses (the regexp is ASCII-only; the punycode form passes). A
104
+ # non-String value (`fields[cid][]=…` arrives as an Array) is rejected through `to_s` rather than
105
+ # raising.
106
+ def normalized_email_address(value)
107
+ address = value.to_s.strip
108
+ address if address.match?(URI::MailTo::EMAIL_REGEXP)
109
+ end
110
+
59
111
  # A visitor's submission is rejected, not escaped, for the same reason an untrusted author's is:
60
112
  # what is stored and redisplayed then equals what was submitted, so rendering it verbatim adds
61
113
  # nothing.
@@ -161,31 +213,38 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
161
213
  cid = f[:cid].to_sym
162
214
  label = f[:label].to_sym
163
215
  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
216
+ when 'text', 'website', 'paragraph', 'textarea', 'email', 'radio', 'checkboxes', 'dropdown', 'file'
217
+ if f[:required].to_s.cama_true? && fields[cid].blank?
218
+ errors << "#{label.to_s.translate}: #{form.the_message('invalid_required',
219
+ t('.error_validation_val',
220
+ default: 'This value is required'))}"
221
+ validate = false
222
+ end
223
+ # Judged by the same rule as the auto-reply recipient (`normalized_email_address`, which is
224
+ # total on any submitted shape -- the submitter chooses whether to send the key at all, and
225
+ # in what shape), so the field validation and the send decision cannot disagree: a malformed
226
+ # address is an error the visitor can act on here, not a success message followed by a
227
+ # confirmation that silently never arrives.
228
+ if (f[:field_type].to_s == 'email') && normalized_email_address(fields[cid]).nil?
229
+ errors << "#{label.to_s.translate}: #{form.the_message('invalid_email',
230
+ t('.email_invalid_val',
231
+ default: 'The e-mail address appears invalid'))}"
232
+ validate = false
233
+ end
234
+ when 'captcha'
235
+ error_message = lambda {
236
+ errors << "#{label.to_s.translate}: #{form.the_message('captcha_not_match',
237
+ t('.captch_error_val',
238
+ default: 'The entered code is incorrect'))}"
239
+ validate = false
240
+ }
241
+
242
+ if form.recaptcha_enabled?
243
+ form.set_captcha_settings!
244
+ error_message.call unless verify_recaptcha
245
+ else
246
+ error_message.call unless cama_captcha_verified?
247
+ end
189
248
  end
190
249
  end
191
250
  validate
@@ -196,23 +255,24 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
196
255
  values = {}
197
256
  form.fields.each do |field|
198
257
  next unless relevant_field?(field)
258
+
199
259
  ft = field[:field_type]
200
260
  cid = field[:cid].to_sym
201
- label = values.keys.include?(field[:label]) ? "#{field[:label]} (#{cid})" : field[:label].to_s.translate
261
+ label = values.key?(field[:label]) ? "#{field[:label]} (#{cid})" : field[:label].to_s.translate
202
262
  values[label] = []
203
263
  if ft == 'file'
204
264
  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'
265
+ values[label] << "#{nr_files} #{'file'.pluralize(nr_files)} (attached)" if fields[cid].present?
266
+ elsif MULTI_VALUE_FIELD_TYPES.include?(ft)
207
267
  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?
268
+ elsif fields[cid].present?
269
+ values[label] << fields[cid]
210
270
  end
211
271
  end
212
272
  values
213
273
  end
214
274
 
215
275
  def relevant_field?(field)
216
- !%w(captcha submit button).include? field[:field_type]
276
+ %w[captcha submit button].exclude?(field[:field_type])
217
277
  end
218
278
  end