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