active_canvas 0.0.5 → 0.1.0

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -1
  3. data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
  4. data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
  5. data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
  6. data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
  7. data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
  8. data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
  9. data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
  10. data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
  11. data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
  12. data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
  13. data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
  14. data/app/assets/javascripts/active_canvas/editor.js +21 -3
  15. data/app/assets/stylesheets/active_canvas/editor.css +626 -0
  16. data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
  17. data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
  18. data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
  19. data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
  20. data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
  21. data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
  22. data/app/controllers/active_canvas/pages_controller.rb +8 -0
  23. data/app/controllers/active_canvas/redirects_controller.rb +10 -0
  24. data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
  25. data/app/models/active_canvas/auto_drop.rb +69 -0
  26. data/app/models/active_canvas/collection.rb +98 -0
  27. data/app/models/active_canvas/collection_item.rb +66 -0
  28. data/app/models/active_canvas/collection_item_version.rb +18 -0
  29. data/app/models/active_canvas/current.rb +2 -0
  30. data/app/models/active_canvas/form_submission.rb +9 -0
  31. data/app/models/active_canvas/page.rb +76 -15
  32. data/app/models/active_canvas/page_redirect.rb +7 -0
  33. data/app/services/active_canvas/collection_schema.rb +110 -0
  34. data/app/services/active_canvas/collection_source.rb +81 -0
  35. data/app/services/active_canvas/content_sanitizer.rb +33 -21
  36. data/app/services/active_canvas/form_schema.rb +48 -0
  37. data/app/services/active_canvas/form_stamper.rb +86 -0
  38. data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
  39. data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
  40. data/app/services/active_canvas/template_renderer.rb +160 -0
  41. data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
  42. data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
  43. data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
  44. data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
  45. data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
  46. data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
  47. data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
  48. data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
  49. data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
  50. data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
  51. data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
  52. data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
  53. data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
  54. data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
  55. data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
  56. data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
  57. data/app/views/active_canvas/pages/show.html.erb +4 -1
  58. data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
  59. data/config/routes.rb +20 -0
  60. data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
  61. data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
  62. data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
  63. data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
  64. data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
  65. data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
  66. data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
  67. data/lib/active_canvas/configuration.rb +32 -1
  68. data/lib/active_canvas/data_sources/errors.rb +51 -0
  69. data/lib/active_canvas/data_sources/param_spec.rb +64 -0
  70. data/lib/active_canvas/data_sources/registration.rb +60 -0
  71. data/lib/active_canvas/data_sources/source.rb +47 -0
  72. data/lib/active_canvas/data_sources.rb +54 -0
  73. data/lib/active_canvas/engine.rb +15 -0
  74. data/lib/active_canvas/version.rb +1 -1
  75. data/lib/active_canvas.rb +6 -0
  76. metadata +85 -3
@@ -0,0 +1,66 @@
1
+ module ActiveCanvas
2
+ class CollectionItem < ApplicationRecord
3
+ belongs_to :collection
4
+ has_many :versions, class_name: "ActiveCanvas::CollectionItemVersion", dependent: :destroy
5
+
6
+ attribute :data, default: {}
7
+ attribute :draft_data, default: {}
8
+
9
+ validates :status, inclusion: { in: %w[draft published] }
10
+
11
+ scope :published, -> { where(status: "published") }
12
+ scope :draft, -> { where(status: "draft") }
13
+
14
+ # Coerce raw form input (string keys = field ids) into draft_data using the
15
+ # collection schema. Unknown fields are dropped; each value is type-coerced.
16
+ def assign_fields(raw)
17
+ self.draft_data = draft_data.merge(schema.coerce_all_for_storage(raw))
18
+ end
19
+
20
+ def pending_changes?
21
+ ids = schema.field_ids
22
+ draft_data.slice(*ids) != data.slice(*ids)
23
+ end
24
+
25
+ # What the admin grid shows: the public snapshot for published items,
26
+ # the draft for everything else.
27
+ def effective_data
28
+ status == "published" ? data : draft_data
29
+ end
30
+
31
+ # Copies the draft into the public snapshot and records a version. The row
32
+ # lock serializes concurrent publishes so version numbers stay monotonic.
33
+ # Call on a saved record: with_lock reloads it. Returns false with an error
34
+ # when a required field is blank. The draft is normalized to the same
35
+ # snapshot, so pending_changes? is false right after publishing.
36
+ def publish
37
+ published = false
38
+ with_lock do
39
+ snapshot = schema.coerce_all_for_storage(draft_data)
40
+ missing = schema.missing_required_labels(snapshot)
41
+ if missing.any?
42
+ errors.add(:base, "Fill in the required fields before publishing: #{missing.join(", ")}")
43
+ else
44
+ update!(data: snapshot, draft_data: snapshot, status: "published", published_at: Time.current)
45
+ versions.create!(data: data, changed_by: Current.editor)
46
+ published = true
47
+ end
48
+ end
49
+ published
50
+ end
51
+
52
+ def publish!
53
+ publish || raise(ActiveRecord::RecordInvalid.new(self))
54
+ end
55
+
56
+ def unpublish!
57
+ with_lock { update!(status: "draft") }
58
+ end
59
+
60
+ private
61
+
62
+ def schema
63
+ CollectionSchema.new(collection.fields)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,18 @@
1
+ module ActiveCanvas
2
+ class CollectionItemVersion < ApplicationRecord
3
+ belongs_to :collection_item
4
+
5
+ attribute :data, default: {}
6
+
7
+ validates :version_number, presence: true, uniqueness: { scope: :collection_item_id }
8
+
9
+ before_validation :set_version_number, on: :create
10
+
11
+ private
12
+
13
+ def set_version_number
14
+ return if version_number.present?
15
+ self.version_number = (collection_item.versions.maximum(:version_number) || 0) + 1
16
+ end
17
+ end
18
+ end
@@ -1,6 +1,8 @@
1
1
  module ActiveCanvas
2
2
  class Current < ActiveSupport::CurrentAttributes
3
3
  attribute :settings_cache
4
+ # Display name of the admin user making the request; recorded on versions.
5
+ attribute :editor
4
6
 
5
7
  def settings_cache
6
8
  super || (self.settings_cache = {})
@@ -0,0 +1,9 @@
1
+ module ActiveCanvas
2
+ class FormSubmission < ApplicationRecord
3
+ belongs_to :page
4
+
5
+ attribute :data, default: {}
6
+
7
+ validates :form_key, presence: true
8
+ end
9
+ end
@@ -2,33 +2,56 @@ module ActiveCanvas
2
2
  class Page < ApplicationRecord
3
3
  belongs_to :page_type
4
4
  has_many :versions, class_name: "ActiveCanvas::PageVersion", dependent: :destroy
5
+ has_many :redirects, class_name: "ActiveCanvas::PageRedirect", dependent: :destroy
6
+ has_many :form_submissions, class_name: "ActiveCanvas::FormSubmission", dependent: :destroy
7
+
8
+ # bindings column is JSON; ensure default and not-null at the model level too.
9
+ attribute :bindings, default: {}
5
10
 
6
11
  validates :title, presence: true
7
12
  validates :slug, uniqueness: true, allow_blank: true
13
+ validate :bindings_shape
8
14
 
9
15
  before_save :set_default_slug
10
16
  before_save :normalize_slug
11
17
  before_save :sanitize_content_if_enabled
12
18
  after_update :create_version_if_content_changed
19
+ after_save :manage_slug_redirects, if: :saved_change_to_slug?
13
20
 
14
21
  scope :published, -> { where(published: true) }
15
22
  scope :draft, -> { where(published: false) }
16
23
 
17
- # Thread-local storage for tracking who made the change
18
- thread_cattr_accessor :current_editor
19
-
20
24
  def to_param
21
25
  id&.to_s
22
26
  end
23
27
 
24
- def rendered_content
25
- ActiveCanvas::ContentRenderer.resolve(content).to_s.html_safe
28
+ def rendered_content(form_feedback: nil, form_action: nil)
29
+ rendered = ActiveCanvas::TemplateRenderer.new(self, mode: :public).render
30
+ rendered = ActiveCanvas::ContentRenderer.resolve(rendered).to_s
31
+ ActiveCanvas::FormStamper.new(rendered, page: self, feedback: form_feedback, action: form_action).stamp.html_safe
26
32
  end
27
33
 
28
34
  def current_version_number
29
35
  versions.maximum(:version_number) || 0
30
36
  end
31
37
 
38
+ # An unsaved copy carrying the editor's current state, for previews. Same
39
+ # id, so form tokens and media references resolve like the real page.
40
+ # Sanitized the way a save would be, so a static preview never shows raw
41
+ # markup that saving would have stripped.
42
+ def preview_with(content: nil, bindings: nil, content_css: nil, content_js: nil, template_enabled: nil)
43
+ # dup marks every attribute dirty, so the sanitizer below re-runs on the copy; that is idempotent and intended.
44
+ dup.tap do |preview|
45
+ preview.id = id
46
+ preview.content = content unless content.nil?
47
+ preview.bindings = bindings unless bindings.nil?
48
+ preview.content_css = content_css unless content_css.nil?
49
+ preview.content_js = content_js unless content_js.nil?
50
+ preview.template_enabled = template_enabled unless template_enabled.nil?
51
+ preview.sanitize_content_if_enabled
52
+ end
53
+ end
54
+
32
55
  # Header/footer display (with fallback for when columns don't exist yet)
33
56
  def show_header?
34
57
  return true unless self.class.column_names.include?("show_header")
@@ -50,10 +73,28 @@ module ActiveCanvas
50
73
  self.slug = slug.parameterize if slug.present?
51
74
  end
52
75
 
76
+ # bindings is `{ "name" => { "source" => "...", ... } }`. Anything else
77
+ # would blow up inside BindingResolver on the public page, so refuse it here.
78
+ def bindings_shape
79
+ return errors.add(:bindings, "must be a JSON object") unless bindings.is_a?(Hash)
80
+
81
+ bindings.each do |name, spec|
82
+ source = spec.is_a?(Hash) ? (spec["source"] || spec[:source]) : nil
83
+ errors.add(:bindings, "entry #{name.to_s.inspect} must be an object with a source") if source.to_s.blank?
84
+ end
85
+ end
86
+
87
+ protected
88
+
89
+ # Dynamic pages are sanitized after Liquid renders (see TemplateRenderer):
90
+ # sanitizing the source would move tags out of tables and mangle `<`.
91
+ # When dynamic rendering is switched off, the stored content is sanitized
92
+ # on that save so nothing unsanitized is ever served raw. Also called by
93
+ # `preview_with` on another instance, so it must be protected, not private.
53
94
  def sanitize_content_if_enabled
54
95
  return unless ActiveCanvas.config.sanitize_content
55
96
 
56
- if content_changed?
97
+ if !template_enabled? && (content_changed? || template_enabled_changed?)
57
98
  self.content = ContentSanitizer.sanitize_html(content)
58
99
  end
59
100
 
@@ -62,24 +103,44 @@ module ActiveCanvas
62
103
  end
63
104
  end
64
105
 
106
+ private
107
+
65
108
  def create_version_if_content_changed
66
- return unless saved_change_to_content? || saved_change_to_content_css?
109
+ content_changed_now = saved_change_to_content?
110
+ css_changed_now = saved_change_to_content_css?
111
+ bindings_changed_now = saved_change_to_bindings?
112
+ return unless content_changed_now || css_changed_now || bindings_changed_now
67
113
 
68
114
  versions.create!(
69
- content_before: content_before_last_save,
70
- content_after: content,
71
- css_before: content_css_before_last_save,
72
- css_after: content_css,
73
- changed_by: self.class.current_editor,
74
- change_summary: generate_change_summary
115
+ content_before: content_before_last_save,
116
+ content_after: content,
117
+ css_before: content_css_before_last_save,
118
+ css_after: content_css,
119
+ bindings_before: bindings_before_last_save,
120
+ bindings_after: bindings,
121
+ changed_by: Current.editor,
122
+ change_summary: generate_change_summary
75
123
  )
76
124
  end
77
125
 
78
126
  def generate_change_summary
79
127
  changes = []
80
- changes << "content updated" if saved_change_to_content?
81
- changes << "CSS updated" if saved_change_to_content_css?
128
+ changes << "content updated" if saved_change_to_content?
129
+ changes << "CSS updated" if saved_change_to_content_css?
130
+ changes << "bindings updated" if saved_change_to_bindings?
82
131
  changes.join(", ").presence || "Updated"
83
132
  end
133
+
134
+ def manage_slug_redirects
135
+ old_slug, new_slug = saved_change_to_slug
136
+
137
+ # A slug now owned by a real page must not redirect elsewhere.
138
+ PageRedirect.where(from_slug: new_slug).delete_all if new_slug.present?
139
+
140
+ return unless published? && old_slug.present?
141
+
142
+ redirect = PageRedirect.find_or_initialize_by(from_slug: old_slug)
143
+ redirect.update!(page: self)
144
+ end
84
145
  end
85
146
  end
@@ -0,0 +1,7 @@
1
+ module ActiveCanvas
2
+ class PageRedirect < ApplicationRecord
3
+ belongs_to :page
4
+
5
+ validates :from_slug, presence: true, uniqueness: true
6
+ end
7
+ end
@@ -0,0 +1,110 @@
1
+ require "date"
2
+
3
+ module ActiveCanvas
4
+ # Pure value object over a Collection's `fields` array. Coerces raw input for
5
+ # JSON storage and stored values for Liquid. The single source of truth for
6
+ # per-type conversion, shared by CollectionItem (write) and CollectionSource (read).
7
+ class CollectionSchema
8
+ VALID_FIELD_TYPES = %w[text textarea rich_text number boolean date select media].freeze
9
+
10
+ def self.generate_field_id(label, taken)
11
+ base = label.to_s.parameterize(separator: "_").tr("-", "_").squeeze("_").gsub(/\A_+|_+\z/, "").presence || "field"
12
+ base = "f_#{base}" unless base.match?(/\A[a-z]/)
13
+ candidate = base
14
+ counter = 1
15
+ while taken.include?(candidate)
16
+ counter += 1
17
+ candidate = "#{base}_#{counter}"
18
+ end
19
+ candidate
20
+ end
21
+
22
+ def initialize(fields)
23
+ @fields = (fields || []).map { |f| f.transform_keys(&:to_s) }
24
+ end
25
+
26
+ def field_ids
27
+ @fields.map { |f| f["id"] }
28
+ end
29
+
30
+ def field(id)
31
+ @fields.find { |f| f["id"] == id.to_s }
32
+ end
33
+
34
+ def coerce_for_storage(field_id, value)
35
+ spec = field(field_id)
36
+ return nil unless spec
37
+
38
+ case spec["type"]
39
+ when "number" then coerce_number(value)
40
+ when "boolean" then ActiveModel::Type::Boolean.new.cast(value) || false
41
+ when "date" then coerce_date(value)
42
+ when "rich_text" then ContentSanitizer.sanitize_html(value.to_s)
43
+ when "media" then value.presence && value.to_i
44
+ when "select" then Array(spec["options"]).include?(value.to_s) ? value.to_s : nil
45
+ else value.to_s.presence
46
+ end
47
+ end
48
+
49
+ # Coerces every schema field present in `raw`; unknown keys are dropped.
50
+ # Used both for form input and to rebuild the published snapshot, so data
51
+ # written around assign_fields (console, seeds) is still sanitized.
52
+ # A select value whose option was removed becomes nil, so a required
53
+ # select can stop a previously valid draft from publishing until it is
54
+ # edited.
55
+ def coerce_all_for_storage(raw)
56
+ raw = (raw || {}).transform_keys(&:to_s)
57
+ field_ids.each_with_object({}) do |id, acc|
58
+ acc[id] = coerce_for_storage(id, raw[id]) if raw.key?(id)
59
+ end
60
+ end
61
+
62
+ # Labels of required fields with no value. Booleans are never "missing":
63
+ # false is a value.
64
+ def missing_required_labels(data)
65
+ data = (data || {}).transform_keys(&:to_s)
66
+ @fields.select { |f| f["required"] && f["type"] != "boolean" && data[f["id"]].blank? }.map { |f| f["label"] }
67
+ end
68
+
69
+ # Values handed to Liquid. Text is HTML-escaped here; rich_text was
70
+ # sanitized on write and is marked html_safe so the resolver leaves it alone.
71
+ def coerce_for_liquid(field_id, value, media_urls: nil)
72
+ spec = field(field_id)
73
+ return nil unless spec
74
+
75
+ case spec["type"]
76
+ when "number", "boolean" then value
77
+ when "date" then (Date.iso8601(value.to_s) rescue nil)
78
+ when "rich_text" then value.to_s.html_safe
79
+ when "media" then ERB::Util.html_escape(resolve_media_url(value, media_urls))
80
+ else ERB::Util.html_escape(value.to_s)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def coerce_number(value)
87
+ return value if value.is_a?(Numeric)
88
+ return nil if value.to_s.strip.empty?
89
+ Integer(value)
90
+ rescue ArgumentError, TypeError
91
+ begin
92
+ Float(value)
93
+ rescue ArgumentError, TypeError
94
+ nil
95
+ end
96
+ end
97
+
98
+ def coerce_date(value)
99
+ return nil if value.to_s.strip.empty?
100
+ Date.parse(value.to_s).iso8601
101
+ rescue ArgumentError, TypeError
102
+ nil
103
+ end
104
+
105
+ def resolve_media_url(value, media_urls)
106
+ return media_urls[value].to_s if media_urls
107
+ ActiveCanvas::Media.find_by(id: value)&.url.to_s
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,81 @@
1
+ module ActiveCanvas
2
+ # Resolves a Collection into Liquid-safe row hashes for a dynamic-data loop.
3
+ # Loads published items, applies equality filter / sort / limit, and maps each
4
+ # item's stored data through the collection schema's Liquid coercion.
5
+ class CollectionSource
6
+ DEFAULT_LIMIT = 100
7
+ MAX_LIMIT = 500
8
+
9
+ def initialize(collection)
10
+ @collection = collection
11
+ @schema = CollectionSchema.new(collection.fields)
12
+ end
13
+
14
+ def resolve(params = {})
15
+ params = (params || {}).transform_keys(&:to_s)
16
+ items = @collection.items.published.to_a
17
+ items = filter(items, params)
18
+ items = sort(items, params)
19
+ items = items.first(clamped_limit(params))
20
+ media_urls = preload_media_urls(items)
21
+ items.map { |item| row(item, media_urls) }
22
+ end
23
+
24
+ private
25
+
26
+ def filter(items, params)
27
+ field = params["filter_field"]
28
+ value = params["filter_value"]
29
+ return items unless field.present? && value.present? && @schema.field(field)
30
+
31
+ items.select { |item| item.data[field].to_s == value.to_s }
32
+ end
33
+
34
+ def sort(items, params)
35
+ spec = @schema.field(params["sort_field"]) if params["sort_field"].present?
36
+ return items.sort_by { |item| [ item.published_at || Time.at(0), item.id ] }.reverse unless spec
37
+
38
+ sortable, unsortable = items.partition { |item| sortable?(spec, item.data[spec["id"]]) }
39
+ sorted = sortable.sort_by { |item| [ sort_key(spec, item.data[spec["id"]]), item.id ] }
40
+ sorted.reverse! unless params["sort_dir"] == "asc"
41
+ sorted + unsortable.sort_by(&:id) # nils and unreadable values go last both ways
42
+ end
43
+
44
+ def sortable?(spec, value)
45
+ case spec["type"]
46
+ when "number" then value.is_a?(Numeric)
47
+ when "boolean" then !value.nil?
48
+ else value.present?
49
+ end
50
+ end
51
+
52
+ def sort_key(spec, value)
53
+ case spec["type"]
54
+ when "number" then value
55
+ when "boolean" then value ? 1 : 0
56
+ else value.to_s.downcase # dates are ISO-8601, so lexical order is chronological
57
+ end
58
+ end
59
+
60
+ def clamped_limit(params)
61
+ limit = Integer(params["limit"], exception: false) if params["limit"].present?
62
+ limit = DEFAULT_LIMIT if limit.nil? || limit <= 0
63
+ [ limit, MAX_LIMIT ].min
64
+ end
65
+
66
+ def preload_media_urls(items)
67
+ media_ids = @schema.field_ids.select { |id| @schema.field(id)["type"] == "media" }
68
+ return {} if media_ids.empty?
69
+
70
+ ids = items.flat_map { |item| media_ids.map { |fid| item.data[fid] } }.compact.uniq
71
+ ActiveCanvas::Media.where(id: ids).index_by(&:id).transform_values(&:url)
72
+ end
73
+
74
+ def row(item, media_urls)
75
+ base = { "id" => item.id, "slug" => item.slug, "published_at" => item.published_at }
76
+ @schema.field_ids.each_with_object(base) do |field_id, acc|
77
+ acc[field_id] = @schema.coerce_for_liquid(field_id, item.data[field_id], media_urls: media_urls)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -1,7 +1,15 @@
1
+ require "securerandom"
2
+
1
3
  module ActiveCanvas
2
4
  class ContentSanitizer
3
5
  class << self
4
- # Sanitize HTML content using Rails' built-in sanitizer
6
+ LIQUID_TAG = /\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\}/
7
+
8
+ # Sanitize HTML content using Rails' built-in sanitizer. Liquid tags are
9
+ # masked first: the HTML parser would otherwise percent-encode `{{ url }}`
10
+ # inside an href and read `{% if a < b %}` as markup. A tag inside a
11
+ # dropped attribute or tag is dropped with it, so masking never unlocks
12
+ # anything the sanitizer would refuse.
5
13
  def sanitize_html(content)
6
14
  return content if content.blank?
7
15
  return content unless ActiveCanvas.config.sanitize_content
@@ -17,7 +25,10 @@ module ActiveCanvas
17
25
  allowed_attributes: config.allowed_html_attributes
18
26
  )
19
27
 
20
- sanitizer.sanitize(content, scrubber: scrubber)
28
+ tags = []
29
+ nonce = SecureRandom.hex(4)
30
+ masked = content.gsub(LIQUID_TAG) { tags << $& ; "acliquid#{nonce}n#{tags.size - 1}z" }
31
+ sanitizer.sanitize(masked, scrubber: scrubber).gsub(/acliquid#{nonce}n(\d+)z/) { tags[$1.to_i] }
21
32
  end
22
33
 
23
34
  # Sanitize CSS content (basic XSS protection)
@@ -59,6 +70,11 @@ module ActiveCanvas
59
70
 
60
71
  # Custom scrubber that allows data-* and aria-* attributes
61
72
  class PermissiveAttributeScrubber < Rails::HTML::PermitScrubber
73
+ # Never restored after Loofah's scrub: browsers tolerate whitespace and
74
+ # control characters inside URL schemes, so any check of our own would
75
+ # be weaker than Loofah's. Loofah is the sole authority on URLs.
76
+ URL_ATTRIBUTES = %w[href src action formaction xlink:href poster].freeze
77
+
62
78
  def initialize(allowed_tags:, allowed_attributes:)
63
79
  super()
64
80
  self.tags = allowed_tags
@@ -70,30 +86,26 @@ module ActiveCanvas
70
86
  true
71
87
  end
72
88
 
73
- def scrub_attribute(node, attr_node)
74
- attr_name = attr_node.name.downcase
75
-
76
- # Allow explicitly listed attributes
77
- return if @allowed_attributes.include?(attr_name)
78
-
79
- # Allow data-* attributes
80
- return if attr_name.start_with?("data-")
89
+ # PermitScrubber only honors an explicit attribute list when
90
+ # `self.attributes` is set; otherwise it defers to Loofah's built-in
91
+ # safelist, which drops attributes we explicitly allow (e.g. `required`).
92
+ # Keep Loofah's security scrubbing, then restore what our config permits.
93
+ def scrub_attributes(node)
94
+ restorable = node.attribute_nodes.select { |attr| explicitly_allowed?(attr) }.to_h { |attr| [ attr.name, attr.value ] }
95
+ super
96
+ restorable.each { |name, value| node[name] = value unless node.attributes.key?(name) }
97
+ end
81
98
 
82
- # Allow aria-* attributes
83
- return if attr_name.start_with?("aria-")
99
+ private
84
100
 
85
- # Check for dangerous attribute values (javascript: URLs, event handlers)
86
- if dangerous_attribute?(attr_name, attr_node.value)
87
- attr_node.remove
88
- return
89
- end
101
+ def explicitly_allowed?(attr_node)
102
+ name = attr_node.name.downcase
103
+ return false if URL_ATTRIBUTES.include?(name)
104
+ return false if dangerous_attribute?(name, attr_node.value)
90
105
 
91
- # Remove unlisted attributes
92
- attr_node.remove
106
+ @allowed_attributes.include?(name) || name.start_with?("data-", "aria-")
93
107
  end
94
108
 
95
- private
96
-
97
109
  def dangerous_attribute?(name, value)
98
110
  # Event handlers
99
111
  return true if name.start_with?("on")
@@ -0,0 +1,48 @@
1
+ module ActiveCanvas
2
+ # Derives a form's definition from a page's saved content. The markup IS the
3
+ # definition: only fields present in the authored <form> are accepted, and
4
+ # `required` attributes drive validation. Shared key derivation keeps the
5
+ # stamper and the endpoint pointing at the same form.
6
+ class FormSchema
7
+ INTERNAL_PREFIX = "ac_".freeze
8
+ EXCLUDED_TYPES = %w[file submit button image reset].freeze
9
+
10
+ Schema = Struct.new(:fields, :required)
11
+
12
+ class << self
13
+ def forms(html)
14
+ Nokogiri::HTML5.fragment(html.to_s).css("form").each_with_index.map do |form, index|
15
+ [ key_for(form, index), form ]
16
+ end
17
+ end
18
+
19
+ def keys(html)
20
+ forms(html).map(&:first)
21
+ end
22
+
23
+ def derive(html, form_key)
24
+ _, form = forms(html).find { |key, _| key == form_key }
25
+ return nil unless form
26
+
27
+ controls = form.css("input, select, textarea").select { |node| accepted?(node) }
28
+ Schema.new(
29
+ controls.map { |node| node["name"] }.uniq,
30
+ controls.select { |node| node.has_attribute?("required") }.map { |node| node["name"] }.uniq
31
+ )
32
+ end
33
+
34
+ def key_for(form, index)
35
+ form["name"].presence || form["id"].presence || "form-#{index + 1}"
36
+ end
37
+
38
+ private
39
+
40
+ def accepted?(node)
41
+ name = node["name"]
42
+ name.present? &&
43
+ !name.start_with?(INTERNAL_PREFIX) &&
44
+ !EXCLUDED_TYPES.include?(node["type"].to_s.downcase)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,86 @@
1
+ module ActiveCanvas
2
+ # Arms authored <form> elements at render time: points them at the engine's
3
+ # public endpoint, injects the signed anti-spam token and honeypot, and
4
+ # drives the no-JS success/error feedback via query params. Idempotent:
5
+ # engine-injected nodes are replaced, never duplicated.
6
+ class FormStamper
7
+ HONEYPOT_FIELD = "ac_website".freeze
8
+ TOKEN_FIELD = "ac_token".freeze
9
+ TOKEN_EXPIRY = 1.week
10
+
11
+ ERROR_MESSAGES = {
12
+ "missing_fields" => "Please fill in all required fields.",
13
+ "too_fast" => "That was too quick — please try again.",
14
+ "invalid" => "Something went wrong. Please try again."
15
+ }.freeze
16
+
17
+ def self.verifier
18
+ Rails.application.message_verifier("active_canvas/forms")
19
+ end
20
+
21
+ def self.token_for(page:, form_key:, issued_at: Time.current)
22
+ verifier.generate(
23
+ { "page_id" => page.id, "form_key" => form_key, "issued_at" => issued_at.to_f },
24
+ expires_in: TOKEN_EXPIRY
25
+ )
26
+ end
27
+
28
+ # action: the mounted submissions path from the request context
29
+ # (engine route helpers outside a request don't know the mount point).
30
+ def initialize(html, page:, feedback: nil, action: nil)
31
+ @html = html.to_s
32
+ @page = page
33
+ @feedback = feedback || {}
34
+ @action = action || Engine.routes.url_helpers.public_form_submissions_path
35
+ end
36
+
37
+ def stamp
38
+ return @html unless @html.include?("<form")
39
+
40
+ doc = Nokogiri::HTML5.fragment(@html)
41
+ doc.css("form").each_with_index do |form, index|
42
+ arm(form, FormSchema.key_for(form, index))
43
+ end
44
+ doc.to_html
45
+ end
46
+
47
+ private
48
+
49
+ def arm(form, form_key)
50
+ form["action"] = @action
51
+ form["method"] = "post"
52
+
53
+ form.css("input[name^='ac_']").each(&:remove)
54
+ form.add_child(hidden_input(TOKEN_FIELD, self.class.token_for(page: @page, form_key: form_key)))
55
+ form.add_child(honeypot_input)
56
+
57
+ ensure_feedback_element(form, "data-ac-success", "Thank you!")
58
+ ensure_feedback_element(form, "data-ac-error", ERROR_MESSAGES["invalid"])
59
+ apply_feedback(form, form_key)
60
+ end
61
+
62
+ def hidden_input(name, value)
63
+ %(<input type="hidden" name="#{name}" value="#{CGI.escapeHTML(value)}">)
64
+ end
65
+
66
+ def honeypot_input
67
+ %(<input type="text" name="#{HONEYPOT_FIELD}" value="" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off">)
68
+ end
69
+
70
+ def ensure_feedback_element(form, attribute, default_text)
71
+ element = form.at_css("[#{attribute}]")
72
+ element ||= form.add_child(%(<div #{attribute}>#{default_text}</div>)).first
73
+ element["hidden"] = ""
74
+ end
75
+
76
+ def apply_feedback(form, form_key)
77
+ if @feedback[:submitted] == form_key
78
+ form.at_css("[data-ac-success]").remove_attribute("hidden")
79
+ elsif @feedback[:error].present? && @feedback[:form_key] == form_key
80
+ error = form.at_css("[data-ac-error]")
81
+ error.content = ERROR_MESSAGES.fetch(@feedback[:error], ERROR_MESSAGES["invalid"])
82
+ error.remove_attribute("hidden")
83
+ end
84
+ end
85
+ end
86
+ end