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.
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,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
- content_changed = @page.content != editor_params[:content]
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(editor_params)
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