active_canvas 0.0.4 → 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,93 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
module Admin
|
|
3
|
+
class CollectionItemsController < ApplicationController
|
|
4
|
+
MAX_ROWS = 1000
|
|
5
|
+
|
|
6
|
+
before_action :set_collection
|
|
7
|
+
before_action :set_item, only: %i[edit update destroy publish unpublish history]
|
|
8
|
+
before_action :load_media, only: %i[new create edit update]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@items = @collection.items.order(updated_at: :desc)
|
|
12
|
+
@items = @items.where(status: params[:status]) if %w[draft published].include?(params[:status])
|
|
13
|
+
@items = @items.limit(MAX_ROWS)
|
|
14
|
+
@leading_fields = @collection.fields.first(4)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def new
|
|
18
|
+
@item = @collection.items.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@item = @collection.items.new(item_params)
|
|
23
|
+
@item.assign_fields(data_params)
|
|
24
|
+
if @item.save
|
|
25
|
+
redirect_to edit_admin_collection_item_path(@collection, @item), notice: "Item saved as draft."
|
|
26
|
+
else
|
|
27
|
+
render :new, status: :unprocessable_entity
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def edit
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def update
|
|
35
|
+
@item.assign_attributes(item_params)
|
|
36
|
+
@item.assign_fields(data_params)
|
|
37
|
+
if @item.save
|
|
38
|
+
redirect_to edit_admin_collection_item_path(@collection, @item), notice: "Item updated."
|
|
39
|
+
else
|
|
40
|
+
render :edit, status: :unprocessable_entity
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def destroy
|
|
45
|
+
@item.destroy
|
|
46
|
+
redirect_to admin_collection_items_path(@collection), notice: "Item deleted."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def publish
|
|
50
|
+
if @item.publish
|
|
51
|
+
redirect_to edit_admin_collection_item_path(@collection, @item), notice: "Item published."
|
|
52
|
+
else
|
|
53
|
+
redirect_to edit_admin_collection_item_path(@collection, @item), alert: @item.errors.full_messages.to_sentence
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unpublish
|
|
58
|
+
@item.unpublish!
|
|
59
|
+
redirect_to edit_admin_collection_item_path(@collection, @item), notice: "Item moved back to draft."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def history
|
|
63
|
+
@versions = @item.versions.order(version_number: :desc)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def set_collection
|
|
69
|
+
@collection = ActiveCanvas::Collection.find(params[:collection_id])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set_item
|
|
73
|
+
@item = @collection.items.find(params[:id])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One query for the media picker instead of one per option.
|
|
77
|
+
def load_media
|
|
78
|
+
@media = ActiveCanvas::Media.with_attached_file.recent
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def item_params
|
|
82
|
+
params.fetch(:item, {}).permit(:slug)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Field ids are dynamic; assign_fields is a schema allowlist, so reading
|
|
86
|
+
# the raw hash here is safe (unknown keys are dropped by the schema).
|
|
87
|
+
def data_params
|
|
88
|
+
data = params.dig(:item, :data)
|
|
89
|
+
data.respond_to?(:to_unsafe_h) ? data.to_unsafe_h : {}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
module Admin
|
|
3
|
+
class CollectionsController < ApplicationController
|
|
4
|
+
before_action :set_collection, only: %i[edit update destroy]
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@collections = ActiveCanvas::Collection.order(:name)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def new
|
|
11
|
+
@collection = ActiveCanvas::Collection.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
@collection = ActiveCanvas::Collection.new(collection_params)
|
|
16
|
+
|
|
17
|
+
if @collection.save
|
|
18
|
+
redirect_to edit_admin_collection_path(@collection), notice: "Collection was successfully created."
|
|
19
|
+
else
|
|
20
|
+
render :new, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def edit
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update
|
|
28
|
+
if @collection.update(collection_params)
|
|
29
|
+
redirect_to edit_admin_collection_path(@collection), notice: "Collection was successfully updated."
|
|
30
|
+
else
|
|
31
|
+
render :edit, status: :unprocessable_entity
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def destroy
|
|
36
|
+
@collection.destroy
|
|
37
|
+
redirect_to admin_collections_path, notice: "Collection was successfully deleted."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def set_collection
|
|
43
|
+
@collection = ActiveCanvas::Collection.find(params[:id])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def collection_params
|
|
47
|
+
params.require(:collection).permit(:name, :slug, :fields_json)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
|
|
3
|
+
module ActiveCanvas
|
|
4
|
+
module Admin
|
|
5
|
+
class FormSubmissionsController < ApplicationController
|
|
6
|
+
MAX_ROWS = 1000
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
@submissions = ActiveCanvas::FormSubmission.includes(:page).order(created_at: :desc)
|
|
10
|
+
@submissions = @submissions.where(page_id: params[:page_id]) if params[:page_id].present?
|
|
11
|
+
@submissions = @submissions.where(form_key: params[:form_key]) if params[:form_key].present?
|
|
12
|
+
@submissions = @submissions.limit(MAX_ROWS)
|
|
13
|
+
|
|
14
|
+
respond_to do |format|
|
|
15
|
+
format.html
|
|
16
|
+
format.csv { send_data csv_for(@submissions), filename: "form-submissions-#{Date.current}.csv" }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
@submission = ActiveCanvas::FormSubmission.find(params[:id])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def destroy
|
|
25
|
+
ActiveCanvas::FormSubmission.find(params[:id]).destroy
|
|
26
|
+
redirect_to admin_form_submissions_path, notice: "Submission deleted."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def csv_for(submissions)
|
|
32
|
+
data_keys = submissions.flat_map { |submission| submission.data.keys }.uniq
|
|
33
|
+
CSV.generate do |csv|
|
|
34
|
+
csv << [ "id", "page", "form", "created_at", "ip", *data_keys ]
|
|
35
|
+
submissions.each do |submission|
|
|
36
|
+
csv << [ submission.id, csv_cell(submission.page.title), csv_cell(submission.form_key),
|
|
37
|
+
submission.created_at.iso8601, submission.ip,
|
|
38
|
+
*data_keys.map { |key| csv_cell(submission.data[key]) } ]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Neutralize CSV formula injection: submitter-controlled values that begin
|
|
44
|
+
# with a formula trigger execute when the export is opened in a spreadsheet.
|
|
45
|
+
def csv_cell(value)
|
|
46
|
+
string = value.to_s
|
|
47
|
+
string.match?(/\A[=+\-@\t\r]/) ? "'#{string}" : string
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -3,7 +3,7 @@ module ActiveCanvas
|
|
|
3
3
|
class PagesController < ApplicationController
|
|
4
4
|
include ActiveCanvas::TailwindCompilation
|
|
5
5
|
|
|
6
|
-
before_action :set_page, only: %i[show edit update destroy content update_content editor save_editor versions]
|
|
6
|
+
before_action :set_page, only: %i[show edit update destroy content update_content editor save_editor versions validate_template sample_data chip_values preview_iframe]
|
|
7
7
|
|
|
8
8
|
def index
|
|
9
9
|
@pages = ActiveCanvas::Page.includes(:page_type).order(created_at: :desc)
|
|
@@ -70,11 +70,20 @@ module ActiveCanvas
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def save_editor
|
|
73
|
-
|
|
73
|
+
attrs = editor_params
|
|
74
|
+
if attrs[:bindings].is_a?(String)
|
|
75
|
+
begin
|
|
76
|
+
attrs[:bindings] = JSON.parse(attrs[:bindings])
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
return render json: { success: false, error: e.message }, status: :unprocessable_entity
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
content_changed = @page.content != attrs[:content]
|
|
74
83
|
Rails.logger.info "[ActiveCanvas::PagesController] save_editor for page ##{@page.id}"
|
|
75
84
|
Rails.logger.info "[ActiveCanvas::PagesController] content_changed: #{content_changed}"
|
|
76
85
|
|
|
77
|
-
if @page.update(
|
|
86
|
+
if @page.update(attrs)
|
|
78
87
|
tailwind_info = compile_tailwind_if_needed(content_changed) do
|
|
79
88
|
compiled_css = ActiveCanvas::TailwindCompiler.compile_for_page(@page)
|
|
80
89
|
@page.update_columns(compiled_tailwind_css: compiled_css, tailwind_compiled_at: Time.current)
|
|
@@ -103,6 +112,102 @@ module ActiveCanvas
|
|
|
103
112
|
@versions = @page.versions.recent.limit(50)
|
|
104
113
|
end
|
|
105
114
|
|
|
115
|
+
# Runs the editor's current source through the strict preview renderer
|
|
116
|
+
# and reports the first error with its position. Never returns HTML.
|
|
117
|
+
def validate_template
|
|
118
|
+
preview = @page.preview_with(content: params[:content].to_s, bindings: parse_bindings(params[:bindings]) || {}, template_enabled: true)
|
|
119
|
+
|
|
120
|
+
if (message = invalid_bindings_message(preview))
|
|
121
|
+
return render json: { ok: false, error: { message: message } }, status: :unprocessable_entity
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
TemplateRenderer.new(preview, mode: :preview).render
|
|
125
|
+
render json: { ok: true, error: nil }
|
|
126
|
+
rescue ActiveCanvas::DataSources::TemplateRenderError => e
|
|
127
|
+
render json: { ok: false, error: { message: e.message, line: e.line, column: e.column } },
|
|
128
|
+
status: :unprocessable_entity
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# First rows of one binding, resolved from the editor's unsaved bindings,
|
|
132
|
+
# so an author can see which field names exist before typing {{ }}.
|
|
133
|
+
# Live values for the editor's chips: what each {{ }} shows in its first
|
|
134
|
+
# occurrence and how many items each loop renders.
|
|
135
|
+
def chip_values
|
|
136
|
+
preview = @page.preview_with(bindings: parse_bindings(params[:bindings]) || {}, template_enabled: true)
|
|
137
|
+
|
|
138
|
+
if (message = invalid_bindings_message(preview))
|
|
139
|
+
return render json: { values: {}, loops: {}, error: message }, status: :unprocessable_entity
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
render json: TemplateRenderer.new(preview, mode: :preview).chip_values(params[:content].to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def sample_data
|
|
146
|
+
preview = @page.preview_with(bindings: parse_bindings(params[:bindings]) || {})
|
|
147
|
+
|
|
148
|
+
if (message = invalid_bindings_message(preview))
|
|
149
|
+
return render json: { error: message }, status: :unprocessable_entity
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
name = params[:binding].to_s
|
|
153
|
+
return render json: { error: "No binding named #{name.inspect}" }, status: :not_found unless preview.bindings.key?(name)
|
|
154
|
+
|
|
155
|
+
render json: { rows: TemplateRenderer::BindingResolver.new(preview.bindings).sample(name) }
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Rails.logger.warn("[ActiveCanvas] sample_data for page #{@page.id} failed: #{e.class}: #{e.message}")
|
|
158
|
+
message = Rails.env.development? ? e.message : "#{e.class}: the data source failed"
|
|
159
|
+
render json: { error: message }, status: :unprocessable_entity
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Renders a complete HTML page (layout, partials, CSS framework) from the
|
|
163
|
+
# editor's current unsaved state, for the preview modal's iframe. Uses the
|
|
164
|
+
# page's own template_enabled flag so a static page previews as static.
|
|
165
|
+
def preview_iframe
|
|
166
|
+
preview = @page.preview_with(
|
|
167
|
+
content: params[:content]&.to_s,
|
|
168
|
+
content_css: params[:content_css]&.to_s,
|
|
169
|
+
content_js: params[:content_js]&.to_s,
|
|
170
|
+
bindings: parse_bindings(params[:bindings])
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if (message = invalid_bindings_message(preview))
|
|
174
|
+
return render json: { html: nil, error: { message: message } }, status: :unprocessable_entity
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@page = preview
|
|
178
|
+
html = render_to_string(template: "active_canvas/pages/show",
|
|
179
|
+
layout: "active_canvas/application",
|
|
180
|
+
formats: [ :html ])
|
|
181
|
+
render json: { html: html, error: nil }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def data_sources
|
|
185
|
+
literal = {
|
|
186
|
+
name: "_literal", kind: "literal", label: "Literal", item_name: "item", list: false,
|
|
187
|
+
params: { value: { type: :string, default: nil, range: nil, allowed: nil } }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
registered = ActiveCanvas::DataSources.registered_names.map do |name|
|
|
191
|
+
source = ActiveCanvas::DataSources.lookup(name)
|
|
192
|
+
{
|
|
193
|
+
name: name, kind: "source", label: name.to_s.humanize,
|
|
194
|
+
item_name: ActiveCanvas::DataSources.item_name(name), list: source.list?,
|
|
195
|
+
params: source.param_schema
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
collections = ActiveCanvas::Collection.order(:name).map do |collection|
|
|
200
|
+
{
|
|
201
|
+
name: collection.slug, kind: "collection", label: collection.name,
|
|
202
|
+
item_name: collection.item_name, list: true,
|
|
203
|
+
fields: collection.fields.map { |f| f.slice("id", "label", "type", "options") },
|
|
204
|
+
params: collection.param_schema
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
render json: [ literal ] + registered + collections
|
|
209
|
+
end
|
|
210
|
+
|
|
106
211
|
private
|
|
107
212
|
|
|
108
213
|
def set_page
|
|
@@ -111,7 +216,7 @@ module ActiveCanvas
|
|
|
111
216
|
|
|
112
217
|
def page_params
|
|
113
218
|
params.require(:page).permit(
|
|
114
|
-
:title, :slug, :content, :page_type_id, :published,
|
|
219
|
+
:title, :slug, :content, :page_type_id, :published, :template_enabled,
|
|
115
220
|
# Header/Footer
|
|
116
221
|
:show_header, :show_footer,
|
|
117
222
|
# SEO fields
|
|
@@ -126,7 +231,24 @@ module ActiveCanvas
|
|
|
126
231
|
end
|
|
127
232
|
|
|
128
233
|
def editor_params
|
|
129
|
-
params.require(:page).permit(:content, :content_css, :content_js, :content_components)
|
|
234
|
+
params.require(:page).permit(:content, :content_css, :content_js, :content_components, :template_enabled, :bindings)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Returns whatever the client sent, parsed. Page#bindings_shape does the
|
|
238
|
+
# checking, so a bad payload becomes a validation error, not a 500.
|
|
239
|
+
def parse_bindings(raw)
|
|
240
|
+
return nil if raw.blank?
|
|
241
|
+
return JSON.parse(raw) if raw.is_a?(String)
|
|
242
|
+
raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
|
243
|
+
rescue JSON::ParserError
|
|
244
|
+
raw
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A dup'd page fails the slug uniqueness check against its own row, so
|
|
248
|
+
# only the bindings errors are meaningful here.
|
|
249
|
+
def invalid_bindings_message(page)
|
|
250
|
+
page.validate
|
|
251
|
+
page.errors.full_messages_for(:bindings).to_sentence.presence
|
|
130
252
|
end
|
|
131
253
|
end
|
|
132
254
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class FormSubmissionsController < ApplicationController
|
|
3
|
+
MAX_VALUE_BYTES = 10.kilobytes
|
|
4
|
+
MAX_PAYLOAD_BYTES = 64.kilobytes
|
|
5
|
+
|
|
6
|
+
# The signed ac_token + origin check replace Rails CSRF for this public,
|
|
7
|
+
# CMS-content-originated endpoint.
|
|
8
|
+
skip_before_action :verify_authenticity_token, raise: false
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
return head :forbidden unless trusted_origin?
|
|
12
|
+
|
|
13
|
+
payload = verified_token
|
|
14
|
+
return head :unprocessable_entity unless payload
|
|
15
|
+
|
|
16
|
+
page = Page.published.find_by(id: payload["page_id"])
|
|
17
|
+
return head :not_found unless page
|
|
18
|
+
|
|
19
|
+
form_key = payload["form_key"]
|
|
20
|
+
return fake_success(page, form_key) if params[:ac_website].present?
|
|
21
|
+
return reject(page, form_key, "too_fast") if too_fast?(payload)
|
|
22
|
+
return head :too_many_requests if rate_limited?
|
|
23
|
+
return head :content_too_large if oversized?
|
|
24
|
+
|
|
25
|
+
schema = FormSchema.derive(page.content, form_key)
|
|
26
|
+
return head :unprocessable_entity unless schema
|
|
27
|
+
|
|
28
|
+
data = params.permit(*schema.fields).to_h
|
|
29
|
+
return reject(page, form_key, "missing_fields") if schema.required.any? { |field| data[field].blank? }
|
|
30
|
+
|
|
31
|
+
submission = page.form_submissions.create!(
|
|
32
|
+
form_key: form_key, data: data,
|
|
33
|
+
ip: request.remote_ip, user_agent: request.user_agent.to_s.first(500)
|
|
34
|
+
)
|
|
35
|
+
notify(submission)
|
|
36
|
+
redirect_to success_path(page, form_key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def trusted_origin?
|
|
42
|
+
source = request.origin.presence || request.referer.presence
|
|
43
|
+
return false unless source
|
|
44
|
+
|
|
45
|
+
URI.parse(source).host == request.host
|
|
46
|
+
rescue URI::InvalidURIError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def verified_token
|
|
51
|
+
FormStamper.verifier.verify(params[:ac_token].to_s)
|
|
52
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def too_fast?(payload)
|
|
57
|
+
Time.current.to_f - payload["issued_at"].to_f < ActiveCanvas.config.form_min_submit_seconds
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rate_limited?
|
|
61
|
+
key = "active_canvas:form_rate:#{request.remote_ip}"
|
|
62
|
+
count = Rails.cache.increment(key, 1, expires_in: 1.minute)
|
|
63
|
+
count.to_i > ActiveCanvas.config.form_rate_limit_per_minute
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def oversized?
|
|
67
|
+
request.content_length.to_i > MAX_PAYLOAD_BYTES ||
|
|
68
|
+
params.to_unsafe_h.values.any? { |value| value.is_a?(String) && value.bytesize > MAX_VALUE_BYTES }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fake_success(page, form_key)
|
|
72
|
+
redirect_to success_path(page, form_key)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reject(page, form_key, reason)
|
|
76
|
+
redirect_to public_page_path(page.slug, ac_form_error: reason, ac_form: form_key, anchor: form_key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def success_path(page, form_key)
|
|
80
|
+
public_page_path(page.slug, ac_submitted: form_key, anchor: form_key)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def notify(submission)
|
|
84
|
+
ActiveSupport::Notifications.instrument("form_submission.active_canvas", submission: submission)
|
|
85
|
+
ActiveCanvas.config.on_form_submission&.call(submission)
|
|
86
|
+
rescue => e
|
|
87
|
+
Rails.logger.error("[ActiveCanvas] form submission hook failed: #{e.class}: #{e.message}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module ActiveCanvas
|
|
2
2
|
class PagesController < ApplicationController
|
|
3
|
+
after_action :set_dynamic_cache_headers, only: %i[show home]
|
|
4
|
+
|
|
3
5
|
def home
|
|
4
6
|
@page = Setting.homepage
|
|
5
7
|
|
|
@@ -14,5 +16,11 @@ module ActiveCanvas
|
|
|
14
16
|
@page = Page.published.find_by(slug: params[:slug])
|
|
15
17
|
raise ActionController::RoutingError, "Not Found" unless @page
|
|
16
18
|
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def set_dynamic_cache_headers
|
|
23
|
+
response.headers["Cache-Control"] = "no-store" if @page&.template_enabled?
|
|
24
|
+
end
|
|
17
25
|
end
|
|
18
26
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class RedirectsController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
page = PageRedirect.find_by(from_slug: params[:slug])&.page
|
|
5
|
+
raise ActionController::RoutingError, "Not Found" unless page&.published? && page.slug.present?
|
|
6
|
+
|
|
7
|
+
redirect_to public_page_path(page.slug), status: :moved_permanently
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
module Admin
|
|
3
|
+
module CollectionItemsHelper
|
|
4
|
+
# The value shown for an item in the grid, read through the schema so
|
|
5
|
+
# removed fields never appear.
|
|
6
|
+
def collection_cell(item, field)
|
|
7
|
+
raw_value = item.effective_data[field["id"]]
|
|
8
|
+
|
|
9
|
+
case field["type"]
|
|
10
|
+
when "boolean" then raw_value ? "✓" : "✗"
|
|
11
|
+
when "media" then media_filenames[raw_value.to_i].to_s
|
|
12
|
+
else truncate(raw_value.to_s, length: 40)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def collection_status_badge(item)
|
|
17
|
+
if item.status != "published"
|
|
18
|
+
tag.span("Draft", class: "badge badge-gray")
|
|
19
|
+
elsif item.pending_changes?
|
|
20
|
+
tag.span("Published · draft changes", class: "badge badge-warning")
|
|
21
|
+
else
|
|
22
|
+
tag.span("Published", class: "badge badge-success")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def media_filenames
|
|
29
|
+
@media_filenames ||= ActiveCanvas::Media.pluck(:id, :filename).to_h
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
# Generic Liquid Drop wrapper that exposes only declared attributes from any
|
|
3
|
+
# object. String values are HTML-escaped on the way out unless the attribute
|
|
4
|
+
# is listed in `html:` or the value is already html_safe. Associations are
|
|
5
|
+
# off by default; each must be opted in with its own whitelist. Association
|
|
6
|
+
# values are always escaped (no `html:` for nested drops). Templates should
|
|
7
|
+
# not add `| escape` on top.
|
|
8
|
+
class AutoDrop < ::Liquid::Drop
|
|
9
|
+
def self.wrap_collection(collection, attributes:, associations: {}, html: [])
|
|
10
|
+
collection.map { |item| new(item, attributes: attributes, associations: associations, html: html) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(record, attributes:, associations: {}, html: [])
|
|
14
|
+
super()
|
|
15
|
+
@record = record
|
|
16
|
+
@attributes = attributes.map(&:to_s).freeze
|
|
17
|
+
@html = html.map(&:to_s).freeze
|
|
18
|
+
@associations = associations.each_with_object({}) do |(key, attrs), acc|
|
|
19
|
+
acc[key.to_s] = attrs
|
|
20
|
+
end.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Liquid's hook for property access. Unknown keys return nil so the
|
|
24
|
+
# surface stays tight; in strict preview mode Liquid reports them.
|
|
25
|
+
def liquid_method_missing(method_name)
|
|
26
|
+
if @attributes.include?(method_name)
|
|
27
|
+
escape(read(method_name), method_name)
|
|
28
|
+
elsif @associations.key?(method_name)
|
|
29
|
+
wrap_association(read(method_name), @associations[method_name])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The exposed surface as a Hash (values still go through escaping and
|
|
34
|
+
# association wrapping). Used by the editor's sample-data endpoint.
|
|
35
|
+
def to_h
|
|
36
|
+
(@attributes + @associations.keys).each_with_object({}) { |name, acc| acc[name] = liquid_method_missing(name) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def read(method_name)
|
|
42
|
+
@record.public_send(method_name) if @record.respond_to?(method_name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def escape(value, attribute)
|
|
46
|
+
case value
|
|
47
|
+
when Array then value.map { |v| escape(v, attribute) }
|
|
48
|
+
when Hash then value.each_with_object({}) { |(k, v), acc| acc[k.to_s] = escape(v, attribute) }
|
|
49
|
+
when Symbol then escape(value.to_s, attribute)
|
|
50
|
+
when String
|
|
51
|
+
value.html_safe? || @html.include?(attribute) ? value : ERB::Util.html_escape(value)
|
|
52
|
+
when Numeric, true, false, nil, Date, Time, ::Liquid::Drop
|
|
53
|
+
value
|
|
54
|
+
else
|
|
55
|
+
raise DataSources::UnsafeData.new(:auto_drop, value.class.name)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def wrap_association(value, attrs)
|
|
60
|
+
if value.nil?
|
|
61
|
+
nil
|
|
62
|
+
elsif value.is_a?(Array) || (value.respond_to?(:to_ary) && !value.is_a?(String))
|
|
63
|
+
AutoDrop.wrap_collection(value, attributes: attrs)
|
|
64
|
+
else
|
|
65
|
+
AutoDrop.new(value, attributes: attrs)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class Collection < ApplicationRecord
|
|
3
|
+
RESERVED_SLUGS = %w[_literal].freeze
|
|
4
|
+
# Keys CollectionSource puts on every row; a field may not shadow them.
|
|
5
|
+
RESERVED_FIELD_IDS = %w[id slug published_at].freeze
|
|
6
|
+
FIELD_ID_FORMAT = /\A[a-z][a-z0-9_]*\z/
|
|
7
|
+
|
|
8
|
+
has_many :items, class_name: "ActiveCanvas::CollectionItem", dependent: :destroy
|
|
9
|
+
|
|
10
|
+
attribute :fields, default: []
|
|
11
|
+
|
|
12
|
+
before_validation :normalize_slug
|
|
13
|
+
before_validation :assign_missing_field_ids
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validates :slug, presence: true, uniqueness: true
|
|
17
|
+
validate :slug_not_reserved
|
|
18
|
+
validate :fields_well_formed
|
|
19
|
+
|
|
20
|
+
def item_name
|
|
21
|
+
ActiveCanvas::DataSources.item_name(slug)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The params an editor can set on a binding to this collection, in the
|
|
25
|
+
# same shape as DataSources::Source#param_schema plus field labels.
|
|
26
|
+
def param_schema
|
|
27
|
+
ids = fields.map { |f| f["id"] }
|
|
28
|
+
labels = fields.each_with_object({}) { |f, acc| acc[f["id"]] = f["label"] }
|
|
29
|
+
{
|
|
30
|
+
limit: { type: :integer, default: CollectionSource::DEFAULT_LIMIT, range: [ 1, CollectionSource::MAX_LIMIT ], allowed: nil },
|
|
31
|
+
sort_field: { type: :string, default: nil, range: nil, allowed: ids, labels: labels },
|
|
32
|
+
sort_dir: { type: :string, default: "desc", range: nil, allowed: %w[asc desc] },
|
|
33
|
+
filter_field: { type: :string, default: nil, range: nil, allowed: ids, labels: labels },
|
|
34
|
+
filter_value: { type: :string, default: nil, range: nil, allowed: nil }
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The field builder posts its rows as one JSON string.
|
|
39
|
+
def fields_json=(json)
|
|
40
|
+
self.fields = json.blank? ? [] : JSON.parse(json)
|
|
41
|
+
rescue JSON::ParserError, TypeError
|
|
42
|
+
@fields_unreadable = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fields_json
|
|
46
|
+
(fields.is_a?(Array) ? fields : []).to_json
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fields=(value)
|
|
50
|
+
@fields_unreadable = false
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def normalize_slug
|
|
57
|
+
self.slug = (slug.presence || name).to_s.parameterize
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def assign_missing_field_ids
|
|
61
|
+
return unless fields_readable?
|
|
62
|
+
|
|
63
|
+
taken = RESERVED_FIELD_IDS.dup
|
|
64
|
+
self.fields = fields.map do |field|
|
|
65
|
+
field = field.transform_keys(&:to_s)
|
|
66
|
+
id = field["id"].presence || CollectionSchema.generate_field_id(field["label"], taken)
|
|
67
|
+
taken << id
|
|
68
|
+
field.merge("id" => id)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def slug_not_reserved
|
|
73
|
+
return if slug.blank?
|
|
74
|
+
|
|
75
|
+
if RESERVED_SLUGS.include?(slug) || ActiveCanvas::DataSources.registered_names.map(&:to_s).include?(slug)
|
|
76
|
+
errors.add(:slug, "is reserved by a registered data source")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fields_readable?
|
|
81
|
+
!@fields_unreadable && fields.is_a?(Array) && fields.all?(Hash)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fields_well_formed
|
|
85
|
+
return errors.add(:fields, "could not be read") unless fields_readable?
|
|
86
|
+
|
|
87
|
+
seen = []
|
|
88
|
+
fields.each do |field|
|
|
89
|
+
errors.add(:fields, "must each have a label") if field["label"].blank?
|
|
90
|
+
errors.add(:fields, "have an unknown type: #{field["type"]}") unless CollectionSchema::VALID_FIELD_TYPES.include?(field["type"])
|
|
91
|
+
errors.add(:fields, "have an invalid id: #{field["id"]} (ids are lower_snake_case; remove the field and add it again)") unless field["id"].to_s.match?(FIELD_ID_FORMAT)
|
|
92
|
+
errors.add(:fields, "use a reserved id: #{field["id"]}") if RESERVED_FIELD_IDS.include?(field["id"])
|
|
93
|
+
errors.add(:fields, "have duplicate ids: #{field["id"]}") if seen.include?(field["id"])
|
|
94
|
+
seen << field["id"]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|