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
@@ -1,27 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public endpoint that receives a form submission (`save_form`) and redisplays the form on refusal.
1
4
  class Plugins::CamaContactForm::FrontController < CamaleonCms::Apps::PluginsFrontController
2
5
  include Plugins::CamaContactForm::MainHelper
3
6
  include Plugins::CamaContactForm::ContactFormControllerConcern
4
-
7
+
5
8
  # here add your custom functions
6
9
  def save_form
7
10
  flash[:contact_form] = {}
8
- @form = current_site.contact_forms.find_by_id(params[:id])
11
+ @form = current_site.contact_forms.find_by(id: params[:id])
9
12
  fields = params[:fields]
10
13
  errors = []
11
14
  success = []
12
15
 
13
- args = {form: @form, values: fields, flag: true}; hooks_run("contact_form_before_submit", args)
16
+ args = { form: @form, values: fields, flag: true }
17
+ hooks_run('contact_form_before_submit', args)
14
18
  if args[:flag]
15
19
  perform_save_form(@form, fields, success, errors)
16
20
  if success.present?
17
21
  flash[:contact_form][:notice] = success.join('<br>')
18
22
  else
19
23
  flash[:contact_form][:error] = errors.join('<br>')
20
- flash[:values] = fields.delete_if{|k, v| v.class.name == 'ActionDispatch::Http::UploadedFile' }
24
+ # Redisplaying the submission is the only reason it ever reaches the page, so a submission
25
+ # containing anything malign is refused whole and echoed back not at all. Filtering it down
26
+ # to the safe fields would be more work for a worse result: the visitor gets a half-filled
27
+ # form, and every future edit to the rendering has to stay in step with the filter.
28
+ #
29
+ # Stamped with the form it was judged against. The gate walks `@form.fields` for the form
30
+ # named by params[:id], but the redisplay page hands flash[:values] to *every* shortcode on
31
+ # it, and auto-generated cids are `c1, c2, …` in every form -- so a value cleared as textarea
32
+ # content for one form was being rendered into a value attribute by another.
33
+ #
34
+ # `@form` is whatever `find_by_id` returned, which is nil for a deleted or bogus id -- a
35
+ # POST anyone can make without credentials. `unsafe_submitted?` fails closed on that, so the
36
+ # guard below is belt and braces for the `@form.id` read on the line after it.
37
+ if @form.present? && fields.respond_to?(:delete_if) && !unsafe_submitted?(@form, fields)
38
+ flash[:values] = fields.delete_if { |_k, v| v.instance_of?(::ActionDispatch::Http::UploadedFile) }
39
+ flash[:values_form_id] = @form.id
40
+ end
21
41
  end
22
42
  end
23
43
  if Rails::VERSION::MAJOR >= 5
24
- params[:format] == 'json' ? render(json: flash.discard(:contact_form).to_hash) : (redirect_back fallback_location: cama_root_path)
44
+ if params[:format] == 'json'
45
+ render(json: flash.discard(:contact_form).to_hash)
46
+ else
47
+ redirect_back fallback_location: cama_root_path
48
+ end
25
49
  else
26
50
  params[:format] == 'json' ? render(json: flash.discard(:contact_form).to_hash) : (redirect_to :back)
27
51
  end
@@ -1,70 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # View helpers that render a form's fields to Bootstrap markup for the `[forms]` shortcode.
1
4
  module Plugins::CamaContactForm::MainHelper
2
5
  include Recaptcha::Adapters::ViewMethods
6
+
3
7
  def self.included(klass)
4
- klass.helper_method [:cama_form_element_bootstrap_object, :cama_form_shortcode] rescue "" # here your methods accessible from views
8
+ klass.helper_method %i[cama_form_element_bootstrap_object cama_form_shortcode]
9
+ rescue StandardError
10
+ ''
11
+ # here your methods accessible from views
5
12
  end
6
13
 
7
14
  def contact_form_on_export(args)
8
- args[:obj][:plugins][self_plugin_key] = JSON.parse(current_site.contact_forms.to_json(:include => [:responses]))
15
+ args[:obj][:plugins][self_plugin_key] = JSON.parse(current_site.contact_forms.to_json(include: [:responses]))
9
16
  end
10
17
 
11
18
  def contact_form_on_import(args)
12
19
  plugins = args[:data][:plugins]
13
- if plugins[self_plugin_key.to_sym].present?
14
- plugins[self_plugin_key.to_sym].each do |contact|
15
- unless current_site.contact_forms.where(slug: contact[:slug]).first.present?
16
- sba_data = ActionController::Parameters.new(contact)
17
- contact_new = current_site.contact_forms.new(sba_data.permit(:name, :slug, :count, :description, :value, :settings))
18
- if contact_new.save!
19
- if contact[:get_field_groups] # save group fields
20
- save_field_group(contact_new, contact[:get_field_groups])
21
- end
22
- save_field_values(contact_new, contact[:field_values])
20
+ return if plugins[self_plugin_key.to_sym].blank?
23
21
 
24
- if contact[:responses].present? # saving responses for this contact
25
- contact[:responses].each do |response|
26
- sba_data = ActionController::Parameters.new(response)
27
- contact_new.responses.create!(sba_data.permit(:name, :slug, :count, :description, :value, :settings))
28
- end
29
- end
30
- args[:messages] << "Saved Plugin Contact Form: #{contact_new.name}"
31
- end
22
+ plugins[self_plugin_key.to_sym].each do |contact|
23
+ next if current_site.contact_forms.where(slug: contact[:slug]).first.present?
24
+
25
+ sba_data = ActionController::Parameters.new(contact)
26
+ contact_new = current_site.contact_forms.new(sba_data.permit(:name, :slug, :count, :description, :value,
27
+ :settings))
28
+ next unless contact_new.save!
29
+
30
+ save_field_group(contact_new, contact[:get_field_groups]) if contact[:get_field_groups] # save group fields
31
+ save_field_values(contact_new, contact[:field_values])
32
+
33
+ if contact[:responses].present? # saving responses for this contact
34
+ contact[:responses].each do |response|
35
+ sba_data = ActionController::Parameters.new(response)
36
+ contact_new.responses.create!(sba_data.permit(:name, :slug, :count, :description, :value, :settings))
32
37
  end
33
38
  end
39
+ args[:messages] << "Saved Plugin Contact Form: #{contact_new.name}"
34
40
  end
35
41
  end
36
42
 
37
43
  # here all actions on plugin destroying
38
44
  # plugin: plugin model
39
- def contact_form_on_destroy(plugin)
40
-
41
- end
45
+ def contact_form_on_destroy(plugin); end
42
46
 
43
47
  # here all actions on going to active
44
48
  # you can run sql commands like this:
45
49
  # results = ActiveRecord::Base.connection.execute(query);
46
50
  # plugin: plugin model
47
- def contact_form_on_active(plugin)
48
-
49
- end
51
+ def contact_form_on_active(plugin); end
50
52
 
51
53
  # here all actions on going to inactive
52
54
  # plugin: plugin model
53
- def contact_form_on_inactive(plugin)
54
-
55
- end
55
+ def contact_form_on_inactive(plugin); end
56
56
 
57
57
  def contact_form_admin_before_load
58
- admin_menu_append_menu_item("settings", {icon: "envelope-o", title: t('plugins.cama_contact_form.title', default: 'Contact Form'), url: admin_plugins_cama_contact_form_admin_forms_path, datas: "data-intro='This plugin permit you to create you contact forms with desired fields and paste your short_code in any content.' data-position='right'"})
58
+ admin_menu_append_menu_item(
59
+ 'settings',
60
+ { icon: 'envelope-o',
61
+ title: t('plugins.cama_contact_form.title', default: 'Contact Form'),
62
+ url: admin_plugins_cama_contact_form_admin_forms_path,
63
+ datas: "data-intro='This plugin permit you to create you contact forms with desired fields " \
64
+ "and paste your short_code in any content.' data-position='right'" }
65
+ )
59
66
  end
60
67
 
61
68
  def contact_form_app_before_load
62
- shortcode_add('forms', plugin_view("forms_shorcode"), "This is a shortocode for contact form to permit you to put your contact form in any content. Sample: [forms slug='key-for-my-form']")
69
+ shortcode_add('forms', plugin_view('forms_shorcode'),
70
+ 'This is a shortocode for contact form to permit you to put your contact form in any ' \
71
+ "content. Sample: [forms slug='key-for-my-form']")
63
72
  end
64
73
 
65
- def contact_form_front_before_load
66
-
67
- end
74
+ def contact_form_front_before_load; end
68
75
 
69
76
  # ============== HTML ==================
70
77
  # This returns the format of the plugin shortcode.
@@ -72,95 +79,202 @@ module Plugins::CamaContactForm::MainHelper
72
79
  "[forms slug=#{slug}]"
73
80
  end
74
81
 
82
+ # Nothing in this file escapes anything, by design. Every value it interpolates -- an author's
83
+ # labels, descriptions, classes, default values, wrappers and templates, a visitor's redisplayed
84
+ # submission, and the structural identifiers the form builder generates -- reaches the page
85
+ # VERBATIM.
86
+ #
87
+ # That is safe because it is enforced at the gate rather than at the sink. The admin controller
88
+ # refuses to store content an untrusted author may not write, and refuses structurally malformed
89
+ # values from anyone; the front controller refuses a submission carrying anything malign and
90
+ # echoes it back not at all. Stored content therefore always equals written content, so rendering
91
+ # it verbatim introduces nothing the writer did not put there -- and an author holding
92
+ # :manage, :contact_form_unfiltered_html can put markup, or script, anywhere in a form and have
93
+ # guests receive it exactly as written.
94
+ #
95
+ # Emit a field's custom attributes verbatim.
96
+ #
97
+ # Camaleon's Hash#to_attr_format is deliberately NOT used here. That method escapes values and
98
+ # drops keys that are not valid attribute names, which is exactly right for its other callers --
99
+ # plugins and themes handing it data from who-knows-where -- but wrong for this one.
100
+ # field_attributes is authored content: it is validated when the form is saved, and an author
101
+ # holding the unfiltered-HTML grant is entitled to emit an event handler through it.
102
+ def cf_attrs(attrs)
103
+ (attrs || {}).map { |key, value| "#{key} = \"#{value}\"" }.join(' ')
104
+ end
105
+
106
+ # Substitute every placeholder in ONE pass, so no replacement is ever scanned for the next
107
+ # placeholder. Substituting in sequence meant `[label ci]` was searched for in the field markup
108
+ # `[ci]` had just produced -- so a visitor typing the literal `[label ci]` into a message had the
109
+ # author's label spliced into the middle of the textarea that was echoing it back.
110
+ #
111
+ # `[ci]` and `[descr ci]` keep first-occurrence semantics and `[label ci]` all-occurrence, which is
112
+ # what the three separate sub/sub/gsub calls did.
113
+ #
114
+ # The block form of #gsub is used throughout: a String replacement would treat `\1` or `\\` in the
115
+ # value as a backreference and silently mangle it.
116
+ CF_PLACEHOLDER = /\[(?:ci|label ci|descr ci)\]/
117
+ CF_REPEATABLE_PLACEHOLDERS = ['[label ci]'].freeze
118
+
119
+ def cf_substitute(template, replacements)
120
+ seen = Hash.new(0)
121
+ template.to_s.gsub(CF_PLACEHOLDER) do |placeholder|
122
+ seen[placeholder] += 1
123
+ if seen[placeholder] == 1 || CF_REPEATABLE_PLACEHOLDERS.include?(placeholder)
124
+ replacements.fetch(placeholder, placeholder)
125
+ else
126
+ placeholder
127
+ end
128
+ end
129
+ end
130
+
75
131
  # form contact with css bootstrap
76
132
  def cama_form_element_bootstrap_object(form, object, values)
77
- html = ""
78
- object.each do |ob|
79
- ob[:label] = ob[:label].to_s.translate
80
- ob[:description] = ob[:description].to_s.translate
81
- r = {field: ob, form: form, template: (ob[:field_options][:template].present? ? ob[:field_options][:template] : Plugins::CamaContactForm::CamaContactForm::field_template), custom_class: (ob[:field_options][:field_class] rescue nil), custom_attrs: {id: ob[:cid] }.merge((JSON.parse(ob[:field_options][:field_attributes]) rescue {})) }
82
- hooks_run("contact_form_item_render", r)
83
- ob = r[:field]
84
- ob[:custom_class] = r[:custom_class]
85
- ob[:custom_attrs] = r[:custom_attrs]
86
- ob[:custom_attrs][:required] = 'true' if ob[:required].present? && ob[:required].to_bool
87
- field_options = ob[:field_options]
88
- for_name = ob[:label].to_s
89
- f_name = "fields[#{ob[:cid]}]"
90
- cid = ob[:cid].to_sym
91
-
92
- temp2 = ""
93
-
94
- case ob[:field_type].to_s
95
- when 'paragraph','textarea'
96
- temp2 = "<textarea #{ob[:custom_attrs].to_attr_format} name=\"#{f_name}\" maxlength=\"#{field_options[:maxlength] || 500 }\" class=\"#{ob[:custom_class].presence || 'form-control'} \">#{values[cid] || ob[:default_value].to_s.translate}</textarea>"
97
- when 'radio'
98
- temp2= cama_form_select_multiple_bootstrap(ob, ob[:label], ob[:field_type],values)
99
- when 'checkboxes'
100
- temp2= cama_form_select_multiple_bootstrap(ob, ob[:label], "checkbox",values)
101
- when 'submit'
102
- temp2 = "<button #{ob[:custom_attrs].to_attr_format} type=\"#{ob[:field_type]}\" name=\"#{f_name}\" class=\"#{ob[:custom_class].presence || 'btn btn-default'}\">#{ob[:label]}</button>"
103
- when 'button'
104
- temp2 = "<button #{ob[:custom_attrs].to_attr_format} type='button' name=\"#{f_name}\" class=\"#{ob[:custom_class].presence || 'btn btn-default'}\">#{ob[:label]}</button>"
105
- when 'reset_button'
106
- temp2 = "<button #{ob[:custom_attrs].to_attr_format} type='reset' name=\"#{f_name}\" class=\"#{ob[:custom_class].presence || 'btn btn-default'}\">#{ob[:label]}</button>"
107
- when 'text', 'website', 'email'
108
- class_type = ""
109
- class_type = "railscf-field-#{ob[:field_type]}" if ob[:field_type]=="website"
110
- class_type = "railscf-field-#{ob[:field_type]}" if ob[:field_type]=="email"
111
- temp2 = "<input #{ob[:custom_attrs].to_attr_format} type=\"#{ob[:field_type]}\" value=\"#{values[cid] || ob[:default_value].to_s.translate}\" name=\"#{f_name}\" class=\"#{ob[:custom_class].presence || 'form-control'} #{class_type}\">"
112
- when 'captcha'
113
- if form.recaptcha_enabled?
114
- temp2 = recaptcha_tags
115
- else
116
- temp2 = cama_captcha_tag(5, {}, {class: "#{ob[:custom_class].presence || 'form-control'} field-captcha required"}.merge(ob[:custom_attrs]))
117
- end
118
- when 'file'
119
- temp2 = "<input multiple=\"multiple\" type=\"file\" value=\"\" name=\"#{f_name}[]\" #{ob[:custom_attrs].to_attr_format} class=\"#{ob[:custom_class].presence || 'form-control'}\">"
120
- when 'dropdown'
121
- temp2 = cama_form_select_multiple_bootstrap(ob, ob[:label], "select",values)
133
+ html = ''
134
+ object.each do |obj|
135
+ obj[:label] = obj[:label].to_s.translate
136
+ obj[:description] = obj[:description].to_s.translate
137
+ template = obj[:field_options][:template].presence ||
138
+ Plugins::CamaContactForm::CamaContactForm.field_template
139
+ r = { field: obj, form: form, template: template, custom_class: begin
140
+ obj[:field_options][:field_class]
141
+ rescue StandardError
142
+ nil
143
+ end, custom_attrs: { id: obj[:cid] }.merge(begin
144
+ JSON.parse(obj[:field_options][:field_attributes])
145
+ rescue StandardError
146
+ {}
147
+ end) }
148
+ hooks_run('contact_form_item_render', r)
149
+ obj = r[:field]
150
+ obj[:custom_class] = r[:custom_class]
151
+ obj[:custom_attrs] = r[:custom_attrs]
152
+ # `cama_true?`, not `to_bool`: `to_bool` raises ArgumentError on anything outside its two
153
+ # patterns, so a stored `required` of `maybe` was a 500 on every visit to the page.
154
+ obj[:custom_attrs][:required] = 'true' if obj[:required].to_s.cama_true?
155
+ field_options = obj[:field_options]
156
+ for_name = obj[:label].to_s
157
+ f_name = "fields[#{obj[:cid]}]"
158
+ cid = obj[:cid].to_sym
159
+
160
+ temp2 = ''
161
+
162
+ # Both a redisplayed submission and the author's default_value render verbatim: each was
163
+ # validated before it could be stored or stashed, so neither can carry anything the submitter
164
+ # was not permitted to write.
165
+ current_value = values[cid] || obj[:default_value].to_s.translate
166
+
167
+ case obj[:field_type].to_s
168
+ when 'paragraph', 'textarea'
169
+ temp2 = "<textarea #{cf_attrs(obj[:custom_attrs])} name=\"#{f_name}\" " \
170
+ "maxlength=\"#{field_options[:maxlength] || 500}\" " \
171
+ "class=\"#{obj[:custom_class].presence || 'form-control'} \">#{current_value}</textarea>"
172
+ when 'radio'
173
+ temp2 = cama_form_select_multiple_bootstrap(obj, obj[:label], obj[:field_type], values)
174
+ when 'checkboxes'
175
+ temp2 = cama_form_select_multiple_bootstrap(obj, obj[:label], 'checkbox', values)
176
+ when 'submit'
177
+ temp2 = "<button #{cf_attrs(obj[:custom_attrs])} type=\"#{obj[:field_type]}\" name=\"#{f_name}\" " \
178
+ "class=\"#{obj[:custom_class].presence || 'btn btn-default'}\">#{obj[:label]}</button>"
179
+ when 'button'
180
+ temp2 = "<button #{cf_attrs(obj[:custom_attrs])} type='button' name=\"#{f_name}\" " \
181
+ "class=\"#{obj[:custom_class].presence || 'btn btn-default'}\">#{obj[:label]}</button>"
182
+ when 'reset_button'
183
+ temp2 = "<button #{cf_attrs(obj[:custom_attrs])} type='reset' name=\"#{f_name}\" " \
184
+ "class=\"#{obj[:custom_class].presence || 'btn btn-default'}\">#{obj[:label]}</button>"
185
+ when 'text', 'website', 'email'
186
+ class_type = ''
187
+ class_type = "railscf-field-#{obj[:field_type]}" if obj[:field_type] == 'website'
188
+ class_type = "railscf-field-#{obj[:field_type]}" if obj[:field_type] == 'email'
189
+ temp2 = "<input #{cf_attrs(obj[:custom_attrs])} type=\"#{obj[:field_type]}\" value=\"#{current_value}\" " \
190
+ "name=\"#{f_name}\" class=\"#{obj[:custom_class].presence || 'form-control'} #{class_type}\">"
191
+ when 'captcha'
192
+ if form.recaptcha_enabled?
193
+ temp2 = recaptcha_tags
122
194
  else
195
+ # The one field type whose attributes do not go through cf_attrs: cama_captcha_tag builds
196
+ # its markup with Rails' `tag`, which escapes values and rewrites malformed names. That is
197
+ # a documented exception to this file's verbatim contract rather than a gap in it -- `tag`
198
+ # is the stricter of the two, so a trusted author's quoted value is escaped here and a
199
+ # malformed attribute name is mangled rather than emitted. Left as it is deliberately:
200
+ # reproducing cf_attrs would mean rebuilding the helper's output by string surgery, which
201
+ # is a real risk of breaking the captcha for a consistency gain and no security gain.
202
+ captcha_class = "#{obj[:custom_class].presence || 'form-control'} field-captcha required"
203
+ temp2 = cama_captcha_tag(5, {}, { class: captcha_class }.merge(obj[:custom_attrs]))
204
+ end
205
+ when 'file'
206
+ temp2 = "<input multiple=\"multiple\" type=\"file\" value=\"\" name=\"#{f_name}[]\" " \
207
+ "#{cf_attrs(obj[:custom_attrs])} class=\"#{obj[:custom_class].presence || 'form-control'}\">"
208
+ when 'dropdown'
209
+ temp2 = cama_form_select_multiple_bootstrap(obj, obj[:label], 'select', values)
123
210
  end
124
- r[:template] = r[:template].sub('[ci]', temp2)
125
- r[:template] = r[:template].sub('[descr ci]', field_options[:description].to_s.translate).sub('<p></p>', '')
126
- html += r[:template].gsub('[label ci]', for_name)
211
+ r[:template] = cf_substitute(r[:template],
212
+ '[ci]' => temp2,
213
+ '[descr ci]' => field_options[:description].to_s.translate,
214
+ '[label ci]' => for_name).sub('<p></p>', '')
215
+ html += r[:template]
127
216
  end
128
217
  html
129
218
  end
130
219
 
131
- def cama_form_select_multiple_bootstrap(ob, title, type, values)
132
- options = ob[:field_options][:options]
133
- include_other_option = ob[:field_options][:include_other_option]
134
- other_input = ""
220
+ # Field control types rendered as a group of <input> tags rather than a <select>.
221
+ RADIO_CHECKBOX_CONTROL_TYPES = %w[radio checkbox].freeze
222
+
223
+ def cama_form_select_multiple_bootstrap(obj, title, type, values)
224
+ # Defensive, because a form saved before the gate required a well-formed option list carries
225
+ # whatever it carries -- and `nil.each`, or `op[:label]` on a String, is a 500 on every visit to
226
+ # the page. `Array()` alone will not do: on a Hash it yields key/value pairs.
227
+ raw_options = obj[:field_options][:options]
228
+ options = (raw_options.is_a?(Hash) ? raw_options.values : Array(raw_options))
229
+ .grep(Hash)
230
+ include_other_option = obj[:field_options][:include_other_option]
231
+ other_input = ''
135
232
 
136
- f_name = "fields[#{ob[:cid]}]"
137
- cid = ob[:cid].to_sym
138
- html = ""
233
+ f_name = "fields[#{obj[:cid]}]"
234
+ cid = obj[:cid].to_sym
235
+ html = ''
139
236
 
140
- if type == "radio" || type == "checkbox"
141
- other_input = (include_other_option)? "<div class=\"#{type} #{ob[:custom_class]}\"> <label for=\"#{ob[:cid]}\"><input id=\"#{ob[:cid]}-other\" type=\"#{type}\" name=\"#{title.downcase}[]\" class=\"\">Other <input type=\"text\" /></label></div>" : " "
237
+ custom_class = obj[:custom_class].to_s
238
+
239
+ if RADIO_CHECKBOX_CONTROL_TYPES.include?(type)
240
+ other_input = if include_other_option
241
+ "<div class=\"#{type} #{custom_class}\"> <label for=\"#{obj[:cid]}\">" \
242
+ "<input id=\"#{obj[:cid]}-other\" type=\"#{type}\" name=\"#{title.downcase}[]\" class=\"\">" \
243
+ 'Other <input type="text" /></label></div>'
244
+ else
245
+ ' '
246
+ end
142
247
  else
143
- html = "<select #{ob[:custom_attrs].to_attr_format} name=\"#{f_name}\" class=\"#{ob[:custom_class]}\">"
248
+ html = "<select #{cf_attrs(obj[:custom_attrs])} name=\"#{f_name}\" class=\"#{custom_class}\">"
144
249
  end
145
250
 
146
251
  options.each do |op|
147
- label = op[:label].translate
148
- if type == "radio" || type == "checkbox"
149
- html += "<div class=\"#{type} #{ob[:custom_class]}\">
150
- <label for=\"#{ob[:cid]}\">
151
- <input #{ob[:custom_attrs].to_attr_format} type=\"#{type}\" #{'checked' if op[:checked].to_s.cama_true?} name=\"#{f_name}[]\" class=\"\" value=\"#{label.downcase}\">
252
+ label = op[:label].to_s.translate
253
+ if RADIO_CHECKBOX_CONTROL_TYPES.include?(type)
254
+ input_tag = "<input #{cf_attrs(obj[:custom_attrs])} type=\"#{type}\" " \
255
+ "#{'checked' if op[:checked].to_s.cama_true?} name=\"#{f_name}[]\" " \
256
+ "class=\"\" value=\"#{label.downcase}\">"
257
+ html += "<div class=\"#{type} #{custom_class}\">
258
+ <label for=\"#{obj[:cid]}\">
259
+ #{input_tag}
152
260
  #{label}
153
261
  </label>
154
262
  </div>"
155
263
  else
156
- html += "<option value=\"#{label.downcase.gsub(" ", "_")}\" #{"selected" if "#{label.downcase.gsub(" ", "_")}" == values[cid] || op[:checked].to_s.cama_true? } >#{label}</option>"
264
+ # A dropdown submits the label lowercased with spaces collapsed to underscores. Derived once
265
+ # so the emitted value and the `selected` comparison cannot drift apart -- but only here:
266
+ # radio and checkbox have always submitted the plain lowercased label, and unifying the two
267
+ # would silently change what every existing response row is compared against.
268
+ option_value = label.downcase.tr(' ', '_')
269
+ html += "<option value=\"#{option_value}\" " \
270
+ "#{'selected' if option_value == values[cid] || op[:checked].to_s.cama_true?} >#{label}</option>"
157
271
  end
158
272
  end
159
273
 
160
- if type == "radio" || type == "checkbox"
161
- html += other_input
162
- else
163
- html += " </select>"
164
- end
274
+ html += if RADIO_CHECKBOX_CONTROL_TYPES.include?(type)
275
+ other_input
276
+ else
277
+ ' </select>'
278
+ end
165
279
  end
166
280
  end
@@ -1,12 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A contact form and, through `parent_id`, its stored response rows, scoped to a site.
1
4
  class Plugins::CamaContactForm::CamaContactForm < ActiveRecord::Base
2
5
  include Plugins::CamaContactForm::MainHelper
6
+
3
7
  self.table_name = 'plugins_contact_forms'
4
- belongs_to :site, class_name: "CamaleonCms::Site"
8
+ belongs_to :site, class_name: 'CamaleonCms::Site'
5
9
  # attr_accessible :site_id, :name, :description, :count, :slug, :value, :settings, :parent_id
6
10
 
7
- has_many :responses, :class_name => "Plugins::CamaContactForm::CamaContactForm", :foreign_key => :parent_id, dependent: :destroy
11
+ # A self-referential parent/child: a form and its stored response rows. There is no reciprocal
12
+ # `belongs_to :parent` to point `inverse_of` at, so the cop is disabled for this association only.
13
+ # rubocop:disable Rails/InverseOf
14
+ has_many :responses, class_name: 'Plugins::CamaContactForm::CamaContactForm', foreign_key: :parent_id,
15
+ dependent: :destroy
16
+ # rubocop:enable Rails/InverseOf
8
17
  validates :name, presence: true
9
- validates_uniqueness_of :slug, scope: :site_id
18
+ validates :slug, uniqueness: { scope: :site_id }
10
19
 
11
20
  before_validation :before_validating
12
21
  before_create :fix_save_settings
@@ -14,64 +23,90 @@ class Plugins::CamaContactForm::CamaContactForm < ActiveRecord::Base
14
23
 
15
24
  default_scope { order(created_at: :desc) }
16
25
 
26
+ # Example of the stored `value` JSON, kept on one line for copy-paste:
27
+ # rubocop:disable Layout/LineLength
17
28
  # [{"label":"Untitled","field_type":"text","required":true,"field_options":{"size":"large","field_class":"Default"},"cid":"c2"},{"label":"Untitled","field_type":"paragraph","required":true,"field_options":{"size":"large","field_class":"Default"},"cid":"c6"},{"label":"Untitled","field_type":"captcha","required":true,"field_options":{"field_class":"Default"},"cid":"c10"},{"label":"Untitled","field_type":"checkboxes","required":true,"field_options":{"options":[{"label":"Default","checked":false},{"label":"Default","checked":false}],"field_class":"Default","description":"description\n"},"cid":"c12"}]
29
+ # rubocop:enable Layout/LineLength
18
30
  def fields
19
- @_the_fields ||= JSON.parse(self.value || '{fields: []}').with_indifferent_access
20
- @_the_fields[:fields]
31
+ @_the_fields ||= JSON.parse(value || '{"fields": []}').with_indifferent_access
32
+ Array(@_the_fields[:fields])
21
33
  end
22
34
 
23
35
  def the_settings
24
- @_the_settings ||= JSON.parse(self.settings || '{}').with_indifferent_access
36
+ @the_settings ||= JSON.parse(settings || '{}').with_indifferent_access
37
+ end
38
+
39
+ # The three settings containers, always readable. A form that has never been saved carries
40
+ # `settings` of `{}`, and a partial update can leave any of them unset -- so every caller in the
41
+ # admin views guards its read with `rescue ''`, while the ones that do not (the public shortcode,
42
+ # the mailer) raised NoMethodError on every request until someone edited the database by hand.
43
+ def mail_settings
44
+ settings_hash(:railscf_mail)
45
+ end
46
+
47
+ def form_button_settings
48
+ settings_hash(:railscf_form_button)
49
+ end
50
+
51
+ def message_settings
52
+ settings_hash(:railscf_message)
25
53
  end
26
54
 
27
55
  def the_message(key, default)
28
- r = self.the_settings[:railscf_message][key].to_s.translate
29
- r.present? ? r : default
56
+ r = message_settings[key].to_s.translate
57
+ r.presence || default
30
58
  end
31
59
 
32
60
  def self.field_template
33
61
  "<div class='form-group'>\n\t <label>[label ci]</label>\n\t<p>[descr ci]</p>\n\t<div>[ci]</div> \n</div>"
34
62
  end
35
-
63
+
36
64
  # define recaptcha settings
37
65
  def set_captcha_settings!
38
- if recaptcha_enabled?
39
- Recaptcha.configure do |config|
40
- config.site_key = the_settings[:recaptcha_site_key]
41
- config.secret_key = the_settings[:recaptcha_secret_key]
42
- end
66
+ return unless recaptcha_enabled?
67
+
68
+ Recaptcha.configure do |config|
69
+ config.site_key = the_settings[:recaptcha_site_key]
70
+ config.secret_key = the_settings[:recaptcha_secret_key]
43
71
  end
44
72
  end
45
-
73
+
46
74
  # verify if recaptcha was enabled for this form
47
- # this method can be overwritten if recaptcha was already defined on initializers to return true as default
75
+ # this method can be overwritten if recaptcha was already defined on initializers to return true as default
48
76
  def recaptcha_enabled?
49
77
  the_settings[:recaptcha_site_key].present?
50
78
  end
51
79
 
52
80
  private
81
+
82
+ def settings_hash(key)
83
+ value = the_settings[key]
84
+ value.is_a?(Hash) ? value : {}.with_indifferent_access
85
+ end
86
+
53
87
  def before_validating
54
88
  slug = self.slug
55
- slug = self.name if slug.blank?
89
+ slug = name if slug.blank?
56
90
  self.slug = slug.to_s.parameterize
57
91
  end
58
92
 
59
93
  def fix_save_settings
60
- self.value = {"fields" => []}.to_json unless self.value.present?
61
- self.settings = {}.to_json unless self.settings.present?
94
+ self.value = { 'fields' => [] }.to_json if value.blank?
95
+ self.settings = {}.to_json if settings.blank?
62
96
  end
63
97
 
64
98
  def delete_uploaded_files
65
- return if self.parent_id.nil?
66
- form = self.class.find_by_id self.parent_id
99
+ return if parent_id.nil?
100
+
101
+ form = self.class.find_by id: parent_id
67
102
  response_data = the_settings[:fields]
68
103
  file_cids = form.fields
69
104
  .select { |f| f[:field_type] == 'file' }
70
105
  .map { |f| f[:cid].to_sym }
71
106
 
72
107
  file_cids
73
- .flat_map { |cid| response_data[cid] }
74
- .map { |file| file.sub Rails.application.routes.url_helpers.cama_root_url, Rails.public_path.to_s }
75
- .each { |file| File.delete file if File.exists? file }
108
+ .flat_map { |cid| response_data[cid] }
109
+ .map { |file| file.sub Rails.application.routes.url_helpers.cama_root_url, Rails.public_path.to_s }
110
+ .each { |file| FileUtils.rm_f file }
76
111
  end
77
- end
112
+ end
@@ -31,7 +31,14 @@
31
31
  <%= text_area_tag "fields[#{cid}][field_options][description]", field[:field_options][:description], class: 'form-control translatable' %>
32
32
  </div>
33
33
  <% case field[:field_type] %>
34
- <% when 'checkboxes', 'radio', 'select', 'dropdown'; options = (field[:field_options][:options] || []) %>
34
+ <%# A refused save redraws this editor from the params the author submitted, which is the %>
35
+ <%# whole point -- but those params are exactly the ones that failed validation, so an %>
36
+ <%# option here may be a bare string or the list may be a hash. Coerced rather than %>
37
+ <%# assumed, or the refusal message is replaced by a 500. %>
38
+ <% when 'checkboxes', 'radio', 'select', 'dropdown'
39
+ raw_options = field[:field_options][:options]
40
+ raw_options = raw_options.values if raw_options.is_a?(Hash)
41
+ options = Array(raw_options).map { |o| o.is_a?(Hash) ? o : { label: o.to_s, checked: false } } %>
35
42
  <label><%= t('.options', default: 'Options') %></label>
36
43
  <ul class="options_sortable" data-options-count="<%= options.size %>">
37
44
  <% options.unshift({label: '', checked: false}).each_with_index do |option, index| %>
@@ -2,9 +2,12 @@
2
2
  @form = attributes['slug'] == '__first__' ? current_site.contact_forms.first : current_site.contact_forms.where({slug: attributes['slug']}).first
3
3
  if @form.present?
4
4
  @form.set_captcha_settings!
5
- values_fields = (flash[:values].present?)? flash[:values].to_sym : {}
5
+ # Only the form the values were judged safe for -- see FrontController#save_form.
6
+ values_fields = (flash[:values].present? && flash[:values_form_id].to_s == @form.id.to_s) ? flash[:values].to_sym : {}
6
7
  values = JSON.parse(@form.value).to_sym
7
- r = {form: @form, form_class: "railscf-form railscf-form-group", before_form: @form.the_settings[:railscf_mail][:previous_html].to_s, after_form: @form.the_settings[:railscf_mail][:after_html].to_s, submit: "<div class='form-group'><button class='submit_btn btn btn-default pull-right' type='submit'>[submit_label]</button></div>"}
8
+ # `mail_settings`/`form_button_settings`, not `the_settings[...]`: a form that has never been
9
+ # saved, or one saved without these containers, otherwise raised NoMethodError on every visit.
10
+ r = {form: @form, form_class: "railscf-form railscf-form-group", before_form: @form.mail_settings[:previous_html].to_s, after_form: @form.mail_settings[:after_html].to_s, submit: "<div class='form-group'><button class='submit_btn btn btn-default pull-right' type='submit'>[submit_label]</button></div>"}
8
11
  hooks_run("contact_form_render", r)
9
12
  %>
10
13
  <section>
@@ -14,7 +17,8 @@
14
17
  <%= render :partial => "camaleon_cms/flash_messages", locals:{ flash: (flash[:contact_form] || {}).with_indifferent_access } %>
15
18
  <%= raw cama_form_element_bootstrap_object(r[:form], values[:fields], values_fields) %>
16
19
  <% if values[:fields].present? && !values[:fields].delete_if{|field| field[:field_type] != 'submit' }.present? %>
17
- <%= raw r[:submit].sub('[submit_label]', @form.the_settings[:railscf_form_button][:name_button])%>
20
+ <%# element content: rendered as markup, refused on save unless the author is trusted %>
21
+ <%= raw r[:submit].sub('[submit_label]') { @form.form_button_settings[:name_button].to_s } %>
18
22
  <% end %>
19
23
  <%= raw front_editor_link(edit_admin_plugins_cama_contact_form_admin_form_url(@form.id)) rescue "" %>
20
24
  <% end %>