cama_contact_form 0.1.0 → 0.1.12

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +204 -0
  3. data/Rakefile +1 -1
  4. data/app/assets/javascripts/plugins/cama_contact_form/admin_editor.js +4 -4
  5. data/app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb +124 -9
  6. data/app/controllers/plugins/cama_contact_form/admin_forms_controller.rb +648 -7
  7. data/app/controllers/plugins/cama_contact_form/front_controller.rb +17 -1
  8. data/app/helpers/plugins/cama_contact_form/main_helper.rb +94 -17
  9. data/app/models/plugins/cama_contact_form/cama_contact_form.rb +25 -3
  10. data/app/views/plugins/cama_contact_form/admin_forms/_item_field.html.erb +8 -1
  11. data/app/views/plugins/cama_contact_form/forms_shorcode.html.erb +7 -3
  12. data/config/camaleon_plugin.json +2 -2
  13. data/config/initializers/custom_models.rb +1 -1
  14. data/config/locales/es.yml +8 -1
  15. data/config/locales/zh-CN.yml +7 -0
  16. data/lib/cama_contact_form/version.rb +1 -1
  17. metadata +7 -83
  18. data/test/cama_contact_form_test.rb +0 -7
  19. data/test/dummy/README.rdoc +0 -28
  20. data/test/dummy/Rakefile +0 -6
  21. data/test/dummy/app/assets/javascripts/application.js +0 -13
  22. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  23. data/test/dummy/app/controllers/application_controller.rb +0 -5
  24. data/test/dummy/app/helpers/application_helper.rb +0 -2
  25. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  26. data/test/dummy/bin/bundle +0 -3
  27. data/test/dummy/bin/rails +0 -4
  28. data/test/dummy/bin/rake +0 -4
  29. data/test/dummy/bin/setup +0 -29
  30. data/test/dummy/config/application.rb +0 -26
  31. data/test/dummy/config/boot.rb +0 -5
  32. data/test/dummy/config/database.yml +0 -25
  33. data/test/dummy/config/environment.rb +0 -5
  34. data/test/dummy/config/environments/development.rb +0 -41
  35. data/test/dummy/config/environments/production.rb +0 -79
  36. data/test/dummy/config/environments/test.rb +0 -42
  37. data/test/dummy/config/initializers/assets.rb +0 -11
  38. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  39. data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
  40. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  41. data/test/dummy/config/initializers/inflections.rb +0 -16
  42. data/test/dummy/config/initializers/mime_types.rb +0 -4
  43. data/test/dummy/config/initializers/session_store.rb +0 -3
  44. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  45. data/test/dummy/config/locales/en.yml +0 -23
  46. data/test/dummy/config/routes.rb +0 -56
  47. data/test/dummy/config/secrets.yml +0 -22
  48. data/test/dummy/config.ru +0 -4
  49. data/test/dummy/public/404.html +0 -67
  50. data/test/dummy/public/422.html +0 -67
  51. data/test/dummy/public/500.html +0 -66
  52. data/test/dummy/public/favicon.ico +0 -0
  53. data/test/integration/navigation_test.rb +0 -8
  54. data/test/test_helper.rb +0 -20
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8f3c768c126ee5bc5444636a26f1aa0423bf74f7e6851ce8d0c656330a137ea
4
- data.tar.gz: c52eea668803c03487b09a4a5af1a21c17b30f18339f899dc5cad06cab79909b
3
+ metadata.gz: b3853b9d6da4e632d947929a801497c0ea6daa25eff3b08834adf1c1aa5d5b86
4
+ data.tar.gz: 2293350d6071301a7795322485e25402974f82ef2dfadd485833a3c7bf89c851
5
5
  SHA512:
6
- metadata.gz: 6dcf81c9421fc18ea88be664ba52d3f5471eeb59fc0aeeb7c1f942a4e7e4eaddfa2823c55d236373c2f0ff206b038116ceb907d52b392f2bfe64d8b66ea37143
7
- data.tar.gz: faca0df162e1a7ca43824b172b41c26ce9b5d86c83b83d75bc5ffa74f925e0fc985705edb46f655bd6a623fe7854567869e5b729a0f54331514eba24b9de974c
6
+ metadata.gz: d6aff50b5f8576691862b332848774945591b00600bae4f0c36bd69852ce15dc90c92f2869b1a21a4d6b2e6bab2a6d623b2c1cd5ef93f70ce7265314d8c96112
7
+ data.tar.gz: e4f5b5977c96e550e3960d07b2fd4c5ae84ed2dae74c4e21f71036815f447476557580ba59c85738bd74f479a4580931d14f67688bfe47067f12d0d83571dcc4
data/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # Camaleon CMS Plugin
2
+ This is a plugin for Camaleon CMS to manage Contact Forms
3
+
4
+ ## Releasing
5
+
6
+ Releases are cut by the **Release** GitHub Actions workflow
7
+ ([`.github/workflows/release_builder.yml`](.github/workflows/release_builder.yml)). It only runs
8
+ when you start it by hand — merging to `master` releases nothing. A single run does everything a
9
+ release needs, in this order:
10
+
11
+ 1. verifies the release is safe to cut (see [What the workflow checks](#what-the-workflow-checks)),
12
+ 2. builds the gem and inspects the built artifact,
13
+ 3. pushes it to RubyGems,
14
+ 4. creates the annotated git tag,
15
+ 5. publishes the GitHub release, with notes taken from `CHANGELOG.md` and the `.gem` plus its
16
+ checksums attached.
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.
20
+
21
+ `lib/cama_contact_form/version.rb` is the single source of truth for the version. The tag, the
22
+ published gem and the GitHub release all derive from it, so they cannot disagree.
23
+
24
+ > **`0.1.12` below is only an example.** Every command, filename and heading in this section uses it
25
+ > as a stand-in for the version you are actually releasing — substitute yours as you go. This
26
+ > document is not updated each release, so do not expect the number here to be the next one.
27
+
28
+ ### Before you start
29
+
30
+ - You need **write access** to this repository.
31
+ - The `RUBY_GEMS_PUBLISH_GEM_KEY` repository secret must exist — it is the RubyGems API key the
32
+ workflow publishes with. It is already configured; you never need to see or handle its value.
33
+ - You do **not** need RubyGems credentials on your own machine for the automated release. You only
34
+ need them for the [manual fallback](#fallback-releasing-entirely-by-hand).
35
+
36
+ ### Step 1 — Open a release PR
37
+
38
+ Create a branch, bump the version, and describe the release:
39
+
40
+ ```bash
41
+ git checkout master && git pull
42
+ git checkout -b release/0.1.12
43
+ ```
44
+
45
+ Edit `lib/cama_contact_form/version.rb`:
46
+
47
+ ```ruby
48
+ module CamaContactForm
49
+ VERSION = "0.1.12"
50
+ end
51
+ ```
52
+
53
+ Then add a matching section at the top of `CHANGELOG.md`, directly under the `# Change Log`
54
+ heading. If an `## Unreleased` section is already sitting there, rename it to the version you are
55
+ shipping and fold your notes into it rather than adding a second section.
56
+
57
+ **The heading must be exactly `## 0.1.12`** — the workflow copies everything between that heading
58
+ and the next `## ` into the GitHub release body, so an inexact heading (`## v0.1.12`,
59
+ `## 0.1.12 (2026-08-01)`, or a leftover `## Unreleased`) silently costs you your release notes and
60
+ falls back to a bare commit list:
61
+
62
+ ```markdown
63
+ ## 0.1.12
64
+
65
+ What changed and why it matters to someone upgrading.
66
+ [#65](https://github.com/owen2345/cama_contact_form/pull/65)
67
+ ```
68
+
69
+ Commit, push, open the PR, and merge it once it is approved:
70
+
71
+ ```bash
72
+ git commit -am "Release 0.1.12"
73
+ git push -u origin release/0.1.12
74
+ gh pr create --title "Release 0.1.12" --body "Bumps the version and adds the changelog entry."
75
+ ```
76
+
77
+ ### Step 2 — Run the Release workflow
78
+
79
+ Merging does **not** publish anything. Once the PR is merged:
80
+
81
+ 1. Open the repository on GitHub.
82
+ 2. Click the **Actions** tab (in the top row, next to *Pull requests*).
83
+ 3. In the left sidebar, under *All workflows*, click **Release**.
84
+ 4. On the right-hand side of the blue banner, click the **Run workflow** dropdown button.
85
+ 5. Leave **Use workflow from** as `Branch: master`. The workflow refuses to run from any other
86
+ branch, so a gem can never be published from unmerged code.
87
+ 6. In **Version to release**, type the version exactly as it appears in `version.rb` — not the
88
+ `0.1.12` of this example. A leading `v` is accepted and stripped, and a mismatch fails the run
89
+ before anything is published.
90
+ 7. Click the green **Run workflow** button.
91
+
92
+ The page takes a few seconds to show the new run; reload if it does not appear. Click into the run
93
+ to watch the three jobs — *Verify and build*, *Publish to RubyGems*, and *Tag and publish GitHub
94
+ release* — go green in turn.
95
+
96
+ > If the **Release** workflow is not listed in step 3, the workflow file has not reached `master`
97
+ > yet. GitHub only offers a manually-triggered workflow once its file exists on the default branch.
98
+ > Merge the PR first.
99
+
100
+ ### Step 3 — Confirm
101
+
102
+ - **RubyGems**: <https://rubygems.org/gems/cama_contact_form/versions/0.1.12> — it can take a
103
+ minute or two to appear.
104
+ - **GitHub**: the repository's *Releases* page should show *Release 0.1.12* with your changelog
105
+ text and three attached files (`.gem`, `.sha256`, `.sha512`).
106
+ - Locally: `gem fetch cama_contact_form -v 0.1.12`
107
+
108
+ ### What the workflow checks
109
+
110
+ Every check runs *before* anything is published, because pushing to RubyGems is the only step that
111
+ cannot be undone — a version can be yanked, but that version number can never be re-used. If a
112
+ check fails, nothing has been published and nothing has been tagged: fix the cause and start the
113
+ workflow again.
114
+
115
+ | Failure message | Cause | Fix |
116
+ | --- | --- | --- |
117
+ | `is not a valid gem version` | Typo in the input box | Re-run with the correct version |
118
+ | `Releases must be cut from master` | *Use workflow from* was not `master` | Re-run from `master` |
119
+ | `does not match lib/cama_contact_form/version.rb` | The typed version and `version.rb` disagree | Bump `version.rb`, or type the version it already has |
120
+ | `Tag <version> already exists` | That version was released before | Bump to a new version |
121
+ | `already published on RubyGems` | Same, but caught on the RubyGems side | Bump to a new version |
122
+ | `No release notes` | No `## <version>` section in `CHANGELOG.md` **and** no commits since the last tag | Add the changelog section |
123
+ | `gem build` fails under `--strict` | The gemspec has a warning (missing homepage, missing `required_ruby_version`, …) | Fix the gemspec |
124
+ | `Test files packaged into the gem` | `s.test_files` was re-added to the gemspec | Remove it — RubyGems merges `test_files` into `files` |
125
+ | `<file> is missing from the gem` | A filename in `s.files` no longer matches a real file | Correct the name in the gemspec |
126
+
127
+ The one failure that needs manual recovery is a job that fails *after* `gem push` succeeded — the
128
+ gem is public, but the tag and release were never created. Do not re-run the workflow, it would
129
+ stop at the "already published" check. Finish it by hand instead, using steps 5 and 6 of the
130
+ fallback below.
131
+
132
+ ### Fallback: releasing entirely by hand
133
+
134
+ Use this if Actions is down, if the workflow is broken, or to finish a release that failed after
135
+ the gem was already pushed. It needs your own RubyGems credentials, and you must be an owner of the
136
+ gem on RubyGems.
137
+
138
+ As above, **`0.1.12` is a stand-in for the version you are releasing** — every command below needs
139
+ your own number substituted in, including the `.gem` filenames.
140
+
141
+ Sign in to RubyGems once per machine — this writes a token to `~/.gem/credentials`:
142
+
143
+ ```bash
144
+ gem signin
145
+ ```
146
+
147
+ Then, from a clean checkout of `master` with the correct version already in `version.rb`:
148
+
149
+ **1. Start from exactly what you intend to publish.**
150
+
151
+ ```bash
152
+ git checkout master && git pull && git status
153
+ ```
154
+
155
+ `git status` must report a clean tree — `gem build` packages the files on disk, so any stray local
156
+ edit would end up in the published gem.
157
+
158
+ **2. Build the gem.**
159
+
160
+ ```bash
161
+ gem build cama_contact_form.gemspec --strict
162
+ ```
163
+
164
+ **3. Inspect what you are about to publish.** This is the step the workflow automates; publishing
165
+ is irreversible, so it is worth the minute.
166
+
167
+ ```bash
168
+ gem unpack cama_contact_form-0.1.12.gem --target /tmp/gem-check
169
+ find /tmp/gem-check -type f | sort
170
+ ```
171
+
172
+ Expect about 26 files in total: `MIT-LICENSE`, `Rakefile`, `README.md`, and the rest under `app/`,
173
+ `config/`, `db/` and `lib/`. There must be **no `test/` directory** and no credentials of any kind.
174
+
175
+ **4. Push to RubyGems.** If your account has MFA enabled for publishing, this prompts for a
176
+ one-time code.
177
+
178
+ ```bash
179
+ gem push cama_contact_form-0.1.12.gem
180
+ ```
181
+
182
+ **5. Create and push the tag.** Annotated (`-a`), to match every existing tag in this repository:
183
+
184
+ ```bash
185
+ git tag -a 0.1.12 -m "Release 0.1.12"
186
+ git push origin 0.1.12
187
+ ```
188
+
189
+ **6. Publish the GitHub release.** Either with the CLI:
190
+
191
+ ```bash
192
+ gh release create 0.1.12 --title "Release 0.1.12" --notes-file notes.md cama_contact_form-0.1.12.gem
193
+ ```
194
+
195
+ where `notes.md` holds the changelog section for this version — or through the web interface:
196
+
197
+ 1. On the repository's front page, click **Releases** in the right-hand sidebar.
198
+ 2. Click **Draft a new release**.
199
+ 3. In **Choose a tag**, select the `0.1.12` tag you pushed in step 5.
200
+ 4. Set **Target** to `master`.
201
+ 5. Set the title to `Release 0.1.12`.
202
+ 6. Paste the `CHANGELOG.md` section for this version into the description box.
203
+ 7. Drag `cama_contact_form-0.1.12.gem` into the *Attach binaries* area at the bottom.
204
+ 8. Click **Publish release**.
data/Rakefile CHANGED
@@ -10,7 +10,7 @@ RDoc::Task.new(:rdoc) do |rdoc|
10
10
  rdoc.rdoc_dir = 'rdoc'
11
11
  rdoc.title = 'CamaContactForm'
12
12
  rdoc.options << '--line-numbers'
13
- rdoc.rdoc_files.include('README.rdoc')
13
+ rdoc.rdoc_files.include('README.md')
14
14
  rdoc.rdoc_files.include('lib/**/*.rb')
15
15
  end
16
16
 
@@ -16,7 +16,7 @@ $(function() {
16
16
  res.find('.options_sortable').sortable({ handle: '.options-sortable' })
17
17
  res.find('.add_option').click().click()
18
18
  res.find('.translatable').Translatable(ADMIN_TRANSLATIONS)
19
- return hideLoading()
19
+ hideLoading()
20
20
  })
21
21
  return false
22
22
  })
@@ -27,7 +27,7 @@ $(function() {
27
27
 
28
28
  const clone = list.children().first().clone().removeClass('hidden')
29
29
  clone.find('input').prop('disabled', false).each(function() {
30
- return $(this).attr('name', $(this).attr('name').replace('[0]', '[' + list.attr('data-options-count') + ']'))
30
+ $(this).attr('name', $(this).attr('name').replace('[0]', '[' + list.attr('data-options-count') + ']'))
31
31
  })
32
32
 
33
33
  list.append(clone)
@@ -42,7 +42,7 @@ $(function() {
42
42
 
43
43
  panel.on('click', '.panel-delete', function() {
44
44
  $(this).closest('li.panel').fadeOut('slow', function() {
45
- return $(this).remove()
45
+ $(this).remove()
46
46
  })
47
47
  return false
48
48
  })
@@ -53,5 +53,5 @@ $(function() {
53
53
  })
54
54
 
55
55
  myFields.find('.options_sortable').sortable({ handle: '.options-sortable' })
56
- return panel.find('.translatable').Translatable(ADMIN_TRANSLATIONS)
56
+ panel.find('.translatable').Translatable(ADMIN_TRANSLATIONS)
57
57
  })
@@ -1,4 +1,19 @@
1
1
  module Plugins::CamaContactForm::ContactFormControllerConcern
2
+ # The field types whose submitted value the renderer interpolates back into the page, and the
3
+ # position each one lands in. Everything else -- radio, checkboxes, dropdown, file -- is only ever
4
+ # compared against, never interpolated, so nothing it contains can escape anything.
5
+ ECHOED_ATTRIBUTE_FIELD_TYPES = %w[text website email].freeze
6
+ ECHOED_TEXTAREA_FIELD_TYPES = %w[paragraph textarea].freeze
7
+
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
10
+ link meta base svg math template].freeze
11
+ ACTIVE_ELEMENT = /<\s*\/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b/i
12
+ EVENT_HANDLER_IN_TAG = /<[a-zA-Z][^>]*\son[a-zA-Z]+\s*=/im
13
+ 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
16
+
2
17
  def perform_save_form(form, fields, success, errors)
3
18
  attachments = []
4
19
  if validate_to_save_form(form, fields, errors)
@@ -26,14 +41,14 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
26
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)
27
42
  if form_new.save
28
43
  fields_data = convert_form_values(form, fields)
29
- message_body = form.the_settings[:railscf_mail][:body].to_s.translate.cama_replace_codes(fields)
30
- 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})
31
- cama_send_email(form.the_settings[:railscf_mail][:to], form.the_settings[:railscf_mail][:subject].to_s.translate.cama_replace_codes(fields), {attachments: attachments, content: content, extra_data: {fields: fields_data}})
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}})
32
47
  success << form.the_message('mail_sent_ok', t('.success_form_val', default: 'Your message has been sent successfully. Thank you very much!'))
33
48
  args = {form: form, values: fields}; hooks_run("contact_form_after_submit", args)
34
- if form.the_settings[:railscf_mail][:to_answer].present? && (answer_to = fields[form.the_settings[:railscf_mail][:to_answer].gsub(/(\[|\])/, '').to_sym]).present?
35
- content = form.the_settings[:railscf_mail][:body_answer].to_s.translate.cama_replace_codes(fields)
36
- cama_send_email(answer_to, form.the_settings[:railscf_mail][:subject_answer].to_s.translate.cama_replace_codes(fields), {content: content})
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})
37
52
  end
38
53
  else
39
54
  errors << form.the_message('mail_sent_ng', t('.error_form_val', default: 'An error occurred, please try again.'))
@@ -41,9 +56,107 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
41
56
  end
42
57
  end
43
58
 
59
+ # A visitor's submission is rejected, not escaped, for the same reason an untrusted author's is:
60
+ # what is stored and redisplayed then equals what was submitted, so rendering it verbatim adds
61
+ # nothing.
62
+ #
63
+ # Memoized because both the validation and the redisplay decision ask the same question about the
64
+ # same two objects in a single request, and the walk is over attacker-chosen structure.
65
+ def unsafe_submitted?(form, fields)
66
+ return @_cf_unsafe_submitted unless @_cf_unsafe_submitted.nil?
67
+
68
+ @_cf_unsafe_submitted = compute_unsafe_submitted?(form, fields)
69
+ end
70
+
71
+ # Fails closed on a missing form: there is nothing to judge the submission against, and the caller
72
+ # must not go on to stash it.
73
+ def compute_unsafe_submitted?(form, fields)
74
+ return true if form.blank?
75
+ return true unless fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters)
76
+
77
+ form.fields.any? { |f| unsafe_submitted_field?(f, fields[f[:cid].to_sym]) }
78
+ end
79
+
80
+ # Two sinks, judged separately because they are different contexts:
81
+ #
82
+ # The notification e-mail interpolates every submitted value into an author-written body and
83
+ # renders the result with `raw`, so a value carrying active markup is refused whatever its type.
84
+ # The test is deliberately narrow -- script, an event handler, a script URL -- and not a
85
+ # sanitizer, which would reject ordinary prose: a visitor writing `Fish & Chips <today>` would be
86
+ # told their message is not allowed, which is both wrong and infuriating.
87
+ #
88
+ # The redisplayed form interpolates the value only for the types listed above, and only into a
89
+ # double-quoted value attribute or into `<textarea>` content. A paragraph is RCDATA, where markup
90
+ # is literal text and only the closing tag ends it, so quotes and angle brackets are ordinary
91
+ # characters there.
92
+ def unsafe_submitted_field?(field, value)
93
+ return false if value.blank?
94
+ return true if submitted_leaves(value).any? { |leaf| active_markup?(leaf) }
95
+
96
+ type = field[:field_type].to_s
97
+ if ECHOED_TEXTAREA_FIELD_TYPES.include?(type)
98
+ echoed_value(value).downcase.include?('</textarea')
99
+ elsif ECHOED_ATTRIBUTE_FIELD_TYPES.include?(type)
100
+ echoed_value(value).include?('"')
101
+ else
102
+ false
103
+ end
104
+ end
105
+
106
+ # The exact string the renderer will emit. `values[cid]` is interpolated directly, so a non-String
107
+ # is judged by its `to_s` -- which is how an Array or a Hash supplies the double quote that closes
108
+ # the attribute without the payload needing one of its own. Judging the leaves instead of the whole
109
+ # missed that; judging `Array#to_s` for *every* type refused every checkbox and every file upload,
110
+ # because `Array#to_s` is `inspect` and always carries quotes.
111
+ def echoed_value(value)
112
+ value.is_a?(String) ? value : value.to_s
113
+ end
114
+
115
+ # An uploaded file is not redisplayed and has no string form worth inspecting; everything else is
116
+ # judged as the mail body will interpolate it.
117
+ def submitted_leaves(value)
118
+ case value
119
+ when ActionController::Parameters then submitted_leaves(value.to_unsafe_h)
120
+ when Hash then value.values.flat_map { |v| submitted_leaves(v) }
121
+ when Array then value.flat_map { |v| submitted_leaves(v) }
122
+ when ActionDispatch::Http::UploadedFile then []
123
+ else [value.to_s]
124
+ end
125
+ end
126
+
127
+ def active_markup?(string)
128
+ string = string.to_s
129
+ return false unless string.include?('<')
130
+
131
+ string.match?(ACTIVE_ELEMENT) || string.match?(EVENT_HANDLER_IN_TAG) || string.match?(URL_SCHEME_IN_TAG)
132
+ end
133
+
44
134
  # form validations
45
135
  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.
141
+ #
142
+ # The message names no field and quotes nothing back. The frontend flash partial renders with
143
+ # `raw`, so echoing the refused value there would make the refusal itself an injection sink --
144
+ # the same trap as the admin path.
145
+ if form.blank? || !(fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters))
146
+ errors << t('.invalid_request_val', default: 'That form could not be submitted. Please try again.')
147
+ return false
148
+ end
149
+
150
+ if unsafe_submitted?(form, fields)
151
+ errors << form.the_message('invalid_content',
152
+ t('.invalid_content_val',
153
+ default: 'Your message contains characters that are not allowed. ' \
154
+ 'Please remove any HTML or quotation marks and try again.'))
155
+ return false
156
+ end
157
+
46
158
  validate = true
159
+
47
160
  form.fields.each do |f|
48
161
  cid = f[:cid].to_sym
49
162
  label = f[:label].to_sym
@@ -53,8 +166,10 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
53
166
  errors << "#{label.to_s.translate}: #{form.the_message('invalid_required', t('.error_validation_val', default: 'This value is required'))}"
54
167
  validate = false
55
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.
56
171
  if f[:field_type].to_s == 'email'
57
- unless fields[cid].match(/@/)
172
+ unless fields[cid].to_s.match(/@/)
58
173
  errors << "#{label.to_s.translate}: #{form.the_message('invalid_email', t('.email_invalid_val', default: 'The e-mail address appears invalid'))}"
59
174
  validate = false
60
175
  end
@@ -86,10 +201,10 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
86
201
  label = values.keys.include?(field[:label]) ? "#{field[:label]} (#{cid})" : field[:label].to_s.translate
87
202
  values[label] = []
88
203
  if ft == 'file'
89
- nr_files = fields[cid].size
204
+ nr_files = Array(fields[cid]).size
90
205
  values[label] << "#{nr_files} #{"file".pluralize(nr_files)} (attached)" if fields[cid].present?
91
206
  elsif ft == 'radio' || ft == 'checkboxes'
92
- values[label] << fields[cid].map { |f| f.to_s.translate }.join(', ') if fields[cid].present?
207
+ values[label] << Array(fields[cid]).map { |f| f.to_s.translate }.join(', ') if fields[cid].present?
93
208
  else
94
209
  values[label] << fields[cid] if fields[cid].present?
95
210
  end