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.
- checksums.yaml +4 -4
- data/README.md +23 -1
- data/app/assets/javascripts/active_canvas/admin/data_panel.js +357 -0
- data/app/assets/javascripts/active_canvas/admin/field_builder.js +84 -0
- data/app/assets/javascripts/active_canvas/admin/media_select_preview.js +11 -0
- data/app/assets/javascripts/active_canvas/editor/ac_bindings.js +81 -0
- data/app/assets/javascripts/active_canvas/editor/ac_chips.js +192 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +10 -7
- data/app/assets/javascripts/active_canvas/editor/directive_traits.js +95 -0
- data/app/assets/javascripts/active_canvas/editor/live_data.js +178 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +23 -7
- data/app/assets/javascripts/active_canvas/editor/preview_modal.js +112 -0
- data/app/assets/javascripts/active_canvas/editor/template_validator.js +95 -0
- data/app/assets/javascripts/active_canvas/editor.js +21 -3
- data/app/assets/stylesheets/active_canvas/editor.css +626 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +8 -0
- data/app/controllers/active_canvas/admin/collection_items_controller.rb +93 -0
- data/app/controllers/active_canvas/admin/collections_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/form_submissions_controller.rb +51 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +127 -5
- data/app/controllers/active_canvas/form_submissions_controller.rb +90 -0
- data/app/controllers/active_canvas/pages_controller.rb +8 -0
- data/app/controllers/active_canvas/redirects_controller.rb +10 -0
- data/app/helpers/active_canvas/admin/collection_items_helper.rb +33 -0
- data/app/models/active_canvas/auto_drop.rb +69 -0
- data/app/models/active_canvas/collection.rb +98 -0
- data/app/models/active_canvas/collection_item.rb +66 -0
- data/app/models/active_canvas/collection_item_version.rb +18 -0
- data/app/models/active_canvas/current.rb +2 -0
- data/app/models/active_canvas/form_submission.rb +9 -0
- data/app/models/active_canvas/page.rb +76 -15
- data/app/models/active_canvas/page_redirect.rb +7 -0
- data/app/services/active_canvas/collection_schema.rb +110 -0
- data/app/services/active_canvas/collection_source.rb +81 -0
- data/app/services/active_canvas/content_sanitizer.rb +33 -21
- data/app/services/active_canvas/form_schema.rb +48 -0
- data/app/services/active_canvas/form_stamper.rb +86 -0
- data/app/services/active_canvas/template_renderer/binding_resolver.rb +138 -0
- data/app/services/active_canvas/template_renderer/directive_expander.rb +54 -0
- data/app/services/active_canvas/template_renderer.rb +160 -0
- data/app/views/active_canvas/admin/collection_items/_field_input.html.erb +40 -0
- data/app/views/active_canvas/admin/collection_items/_form.html.erb +28 -0
- data/app/views/active_canvas/admin/collection_items/edit.html.erb +24 -0
- data/app/views/active_canvas/admin/collection_items/history.html.erb +34 -0
- data/app/views/active_canvas/admin/collection_items/index.html.erb +63 -0
- data/app/views/active_canvas/admin/collection_items/new.html.erb +9 -0
- data/app/views/active_canvas/admin/collections/_field_builder.html.erb +35 -0
- data/app/views/active_canvas/admin/collections/_form.html.erb +31 -0
- data/app/views/active_canvas/admin/collections/edit.html.erb +10 -0
- data/app/views/active_canvas/admin/collections/index.html.erb +45 -0
- data/app/views/active_canvas/admin/collections/new.html.erb +9 -0
- data/app/views/active_canvas/admin/form_submissions/index.html.erb +80 -0
- data/app/views/active_canvas/admin/form_submissions/show.html.erb +99 -0
- data/app/views/active_canvas/admin/pages/_data_panel.html.erb +81 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +10 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +62 -1
- data/app/views/active_canvas/pages/show.html.erb +4 -1
- data/app/views/layouts/active_canvas/admin/application.html.erb +15 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20260517100001_add_template_enabled_and_bindings_to_active_canvas_pages.rb +6 -0
- data/db/migrate/20260517100002_add_bindings_to_active_canvas_page_versions.rb +6 -0
- data/db/migrate/20260702000001_create_active_canvas_page_redirects.rb +11 -0
- data/db/migrate/20260702000002_create_active_canvas_form_submissions.rb +14 -0
- data/db/migrate/20260702000003_create_active_canvas_collections.rb +12 -0
- data/db/migrate/20260702000004_create_active_canvas_collection_items.rb +16 -0
- data/db/migrate/20260702000005_create_active_canvas_collection_item_versions.rb +17 -0
- data/lib/active_canvas/configuration.rb +32 -1
- data/lib/active_canvas/data_sources/errors.rb +51 -0
- data/lib/active_canvas/data_sources/param_spec.rb +64 -0
- data/lib/active_canvas/data_sources/registration.rb +60 -0
- data/lib/active_canvas/data_sources/source.rb +47 -0
- data/lib/active_canvas/data_sources.rb +54 -0
- data/lib/active_canvas/engine.rb +15 -0
- data/lib/active_canvas/version.rb +1 -1
- data/lib/active_canvas.rb +6 -0
- 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
|
|
@@ -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::
|
|
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
|
-
|
|
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:
|
|
70
|
-
content_after:
|
|
71
|
-
css_before:
|
|
72
|
-
css_after:
|
|
73
|
-
|
|
74
|
-
|
|
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"
|
|
81
|
-
changes << "CSS updated"
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
return if attr_name.start_with?("aria-")
|
|
99
|
+
private
|
|
84
100
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|