panda-cms 0.10.0 → 0.10.2

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -11
  3. data/app/components/panda/cms/code_component.rb +45 -8
  4. data/app/components/panda/cms/menu_component.rb +9 -3
  5. data/app/components/panda/cms/page_menu_component.rb +9 -1
  6. data/app/components/panda/cms/rich_text_component.rb +49 -17
  7. data/app/components/panda/cms/text_component.rb +46 -14
  8. data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
  9. data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
  10. data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
  11. data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
  12. data/app/controllers/panda/cms/pages_controller.rb +7 -2
  13. data/app/controllers/panda/cms/posts_controller.rb +16 -0
  14. data/app/helpers/panda/cms/application_helper.rb +2 -3
  15. data/app/helpers/panda/cms/asset_helper.rb +14 -72
  16. data/app/helpers/panda/cms/forms_helper.rb +60 -0
  17. data/app/helpers/panda/cms/seo_helper.rb +85 -0
  18. data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
  19. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
  20. data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
  21. data/app/javascript/panda/cms/controllers/index.js +6 -0
  22. data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
  23. data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
  24. data/app/javascript/panda/cms/stimulus-loading.js +2 -1
  25. data/app/models/panda/cms/menu.rb +12 -0
  26. data/app/models/panda/cms/page.rb +106 -0
  27. data/app/models/panda/cms/post.rb +97 -0
  28. data/app/views/layouts/homepage.html.erb +1 -4
  29. data/app/views/layouts/page.html.erb +1 -4
  30. data/app/views/panda/cms/admin/dashboard/show.html.erb +1 -1
  31. data/app/views/panda/cms/admin/files/index.html.erb +1 -1
  32. data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
  33. data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
  34. data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
  35. data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
  36. data/app/views/panda/cms/admin/menus/new.html.erb +5 -7
  37. data/app/views/panda/cms/admin/pages/edit.html.erb +139 -20
  38. data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
  39. data/app/views/panda/cms/admin/posts/_form.html.erb +41 -2
  40. data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
  41. data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
  42. data/app/views/shared/_header.html.erb +1 -4
  43. data/config/brakeman.ignore +38 -0
  44. data/config/importmap.rb +8 -6
  45. data/config/locales/en.yml +41 -0
  46. data/config/routes.rb +1 -1
  47. data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
  48. data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
  49. data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
  50. data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
  51. data/lib/panda/cms/asset_loader.rb +27 -77
  52. data/lib/panda/cms/bulk_editor.rb +288 -12
  53. data/lib/panda/cms/engine/asset_config.rb +49 -0
  54. data/lib/panda/cms/engine/autoload_config.rb +19 -0
  55. data/lib/panda/cms/engine/backtrace_config.rb +42 -0
  56. data/lib/panda/cms/engine/core_config.rb +106 -0
  57. data/lib/panda/cms/engine/helper_config.rb +20 -0
  58. data/lib/panda/cms/engine/route_config.rb +34 -0
  59. data/lib/panda/cms/engine/view_component_config.rb +31 -0
  60. data/lib/panda/cms/engine.rb +44 -221
  61. data/lib/panda/cms.rb +10 -0
  62. data/lib/panda-cms/version.rb +1 -1
  63. data/lib/panda-cms.rb +16 -2
  64. metadata +20 -22
  65. data/app/javascript/panda_cms/stimulus-loading.js +0 -39
  66. data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
  67. data/config/initializers/inflections.rb +0 -5
  68. data/lib/tasks/assets.rake +0 -540
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5d7ab51a41583d6af2b9c03249894b81eb3301e8a88068bf512fea5ba10891a
4
- data.tar.gz: 9cbbd69b5b6e6ce229fef244135e7abb1e12580be401524b054e94ff227280cc
3
+ metadata.gz: 64a39a57fd00f6001e7992e634d5aef7b0536e7bfc4295399ea23737c10b0d83
4
+ data.tar.gz: 1b6d908a7f04e2f35ac7402b0c687160e71ac11e939a47f4193e1b4083947019
5
5
  SHA512:
6
- metadata.gz: 84f46d5e374a05e046047e7e7751371f4b90d2e86a137df179c02e6e89c08169048e64c18ef38b0b78ab5137c22f55d0777451ab49bb32ab3aa3c67745b6aaed
7
- data.tar.gz: 0b6ad4481db935ed61ca57d087582e8b370fe2ecb6b0e15dadeaa53042a834dbecaf9cd7d0122830788e10b071cce66290d0abd6a0b4649adf23465140854069
6
+ metadata.gz: eb5a888a0e743db6524e89c35de782c4fa51ed3e0c903dfa0e80b989764bdc3e59e8f90a4cb4837cfe35b286e4edba56451442fb3338a49165c5bbe9bb81b75f
7
+ data.tar.gz: f987d4e8feaf6ae5fdf6c217c591c52382cd04f67e9e7d78f1d4f3e6834e1f978a549ade07b6fbee13e7d42b98f6f7e1245ce68b5502562f548c2461dd4ade1c
data/README.md CHANGED
@@ -32,16 +32,6 @@ The easiest way for you to get started is to visit http://localhost:3000/admin a
32
32
 
33
33
  When you're ready to configure further, you can set your own configuration in `config/initializers/panda.rb`. Make sure to configure your authentication providers and update the domain restriction!
34
34
 
35
- ## Panda CMS Pro
36
-
37
- Commercial features such as structured **Collections** live in the `panda-cms-pro` gem. Once the pro gem is installed you can:
38
-
39
- - Model repeatable content with collections and items.
40
- - Loop over entries inside layouts using helpers like `panda_cms_collection_items("trustees")`.
41
- - Keep the feature hidden in open-source installs thanks to `Panda::CMS::Features`.
42
-
43
- See `docs/collections.md` for the editor workflow and template examples, and `docs/private-gem-server.md` for hosting the private gem server.
44
-
45
35
  ### Existing applications
46
36
 
47
37
  Add the following to `Gemfile`:
@@ -124,6 +114,7 @@ The CMS automatically loads Core's compiled stylesheet:
124
114
  ```
125
115
 
126
116
  Core's Rack middleware serves this file from the gem, so:
117
+
127
118
  - ✅ No CSS copying or compilation needed
128
119
  - ✅ Styles update automatically when Core updates
129
120
  - ✅ Consistent design across all Panda gems
@@ -136,7 +127,7 @@ For CSS compilation (when contributing to styling), see [Panda Core Asset Compil
136
127
 
137
128
  This is a non-exhuastive list (there will be many more):
138
129
 
139
- * To date, this has only been tested with Rails 7.1, 7.2 and 8.0
130
+ * To date, this has only been tested with Rails 7.1, 7.2 and 8
140
131
  * There may be conflicts if you're not using Tailwind CSS on the frontend. Please report this.
141
132
 
142
133
  ## Contributing
@@ -14,10 +14,12 @@ module Panda
14
14
  prop :editable, _Boolean, default: true
15
15
 
16
16
  def view_template
17
- if @editable_state
18
- render_editable_view
17
+ # Russian doll caching: Cache component output at block_content level
18
+ # Only cache in non-editable mode (public-facing pages)
19
+ if should_cache?
20
+ raw cache_component_output
19
21
  else
20
- raw(@code_content.to_s.html_safe)
22
+ render_content
21
23
  end
22
24
  rescue => e
23
25
  handle_error(e)
@@ -36,9 +38,9 @@ module Panda
36
38
  block = find_block
37
39
  return false if block.nil?
38
40
 
39
- block_content = find_block_content(block)
40
- @code_content = block_content&.content.to_s
41
- @block_content_id = block_content&.id
41
+ @block_content_obj = find_block_content(block)
42
+ @code_content = @block_content_obj&.content.to_s
43
+ @block_content_id = @block_content_obj&.id
42
44
  end
43
45
 
44
46
  def find_block
@@ -123,8 +125,11 @@ module Panda
123
125
  end
124
126
 
125
127
  def is_embedded?
126
- # TODO: Check security on this - embed_id should match something?
127
- view_context.request.params[:embed_id].present?
128
+ # Security: Verify embed_id matches the current page being edited
129
+ # This prevents unauthorized editing by ensuring the embed_id in the URL
130
+ # matches the actual page ID from Current.page
131
+ view_context.params[:embed_id].present? &&
132
+ Current.page&.id.to_s == view_context.params[:embed_id].to_s
128
133
  end
129
134
 
130
135
  def handle_error(error)
@@ -141,6 +146,38 @@ module Panda
141
146
  end
142
147
  end
143
148
 
149
+ def render_content
150
+ if @editable_state
151
+ render_editable_view
152
+ else
153
+ raw(@code_content.to_s.html_safe)
154
+ end
155
+ end
156
+
157
+ def should_cache?
158
+ !@editable_state &&
159
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
160
+ @block_content_obj.present?
161
+ end
162
+
163
+ def cache_component_output
164
+ cache_key = cache_key_for_component
165
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
166
+
167
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
168
+ render_content_to_string
169
+ end.html_safe
170
+ end
171
+
172
+ def cache_key_for_component
173
+ "panda_cms/code_component/#{@block_content_obj.cache_key_with_version}/#{@key}"
174
+ end
175
+
176
+ def render_content_to_string
177
+ # For code component, we just return the raw HTML content
178
+ @code_content.to_s
179
+ end
180
+
144
181
  class BlockError < StandardError; end
145
182
  end
146
183
  end
@@ -44,9 +44,15 @@ module Panda
44
44
  @menu = Panda::CMS::Menu.find_by(name: @name)
45
45
  return unless @menu
46
46
 
47
- menu_items = @menu.menu_items
48
- menu_items = menu_items.where("depth <= ?", @menu.depth) if @menu.depth
49
- menu_items = menu_items.order(:lft)
47
+ # Fragment caching: Cache menu_items query results
48
+ # Cache key includes menu's updated_at to auto-invalidate on changes
49
+ cache_key = "panda_cms_menu/#{@menu.name}/#{@menu.id}/#{@menu.updated_at.to_i}/items"
50
+
51
+ menu_items = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
52
+ items = @menu.menu_items
53
+ items = items.where("depth <= ?", @menu.depth) if @menu.depth
54
+ items.order(:lft).to_a # Convert to array for caching
55
+ end
50
56
 
51
57
  @processed_menu_items = menu_items.map do |menu_item|
52
58
  add_css_classes_to_item(menu_item)
@@ -36,7 +36,15 @@ module Panda
36
36
  menu = @start_page&.page_menu
37
37
  return if menu.nil?
38
38
 
39
- @menu_item = menu.menu_items.order(:lft)&.first
39
+ # Fragment caching: Cache menu items for this page menu
40
+ # Cache key includes menu's updated_at to auto-invalidate on changes
41
+ cache_key = "panda_cms_page_menu/#{menu.id}/#{menu.updated_at.to_i}/items"
42
+
43
+ cached_items = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
44
+ menu.menu_items.order(:lft).to_a
45
+ end
46
+
47
+ @menu_item = cached_items.first
40
48
 
41
49
  # Set default styles if not already set
42
50
  @styles[:indent_with] ||= "pl-2" if @styles
@@ -18,6 +18,16 @@ module Panda
18
18
  attr_accessor :content, :block_content_id
19
19
 
20
20
  def view_template
21
+ # Russian doll caching: Cache component output at block_content level
22
+ # Only cache in non-editable mode (public-facing pages)
23
+ if should_cache?
24
+ raw cache_component_output
25
+ else
26
+ render_content
27
+ end
28
+ end
29
+
30
+ def render_content
21
31
  div(class: "panda-cms-content", **element_attrs) do
22
32
  if @editable_state
23
33
  # Empty div for EditorJS to initialize into
@@ -199,23 +209,21 @@ module Panda
199
209
  attrs = {class: "panda-cms-content"}
200
210
 
201
211
  if @editable_state
202
- attrs.merge!(
203
- id: "editor-#{@block_content_id}",
204
- data: {
205
- "editable-previous-data": @encoded_data,
206
- "editable-content": @encoded_data,
207
- "editable-initialized": "false",
208
- "editable-version": "2.28.2",
209
- "editable-autosave": "false",
210
- "editable-tools": '{"paragraph":true,"header":true,"list":true,"quote":true,"table":true}',
211
- "editable-kind": "rich_text",
212
- "editable-block-content-id": @block_content_id,
213
- "editable-page-id": Current.page.id,
214
- controller: "editor-js",
215
- "editor-js-initialized-value": "false",
216
- "editor-js-content-value": @encoded_data
217
- }
218
- )
212
+ attrs[:id] = "editor-#{@block_content_id}"
213
+ attrs[:data] = {
214
+ "editable-previous-data": @encoded_data,
215
+ "editable-content": @encoded_data,
216
+ "editable-initialized": "false",
217
+ "editable-version": "2.28.2",
218
+ "editable-autosave": "false",
219
+ "editable-tools": '{"paragraph":true,"header":true,"list":true,"quote":true,"table":true}',
220
+ "editable-kind": "rich_text",
221
+ "editable-block-content-id": @block_content_id,
222
+ "editable-page-id": Current.page.id,
223
+ controller: "editor-js",
224
+ "editor-js-initialized-value": "false",
225
+ "editor-js-content-value": @encoded_data
226
+ }
219
227
  end
220
228
 
221
229
  attrs
@@ -247,6 +255,30 @@ module Panda
247
255
 
248
256
  nil
249
257
  end
258
+
259
+ def should_cache?
260
+ !@editable_state &&
261
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
262
+ @block_content.present?
263
+ end
264
+
265
+ def cache_component_output
266
+ cache_key = cache_key_for_component
267
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
268
+
269
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
270
+ render_content_to_string
271
+ end.html_safe
272
+ end
273
+
274
+ def cache_key_for_component
275
+ "panda_cms/rich_text_component/#{@block_content.cache_key_with_version}/#{@key}"
276
+ end
277
+
278
+ def render_content_to_string
279
+ # Render the component HTML to a string for caching
280
+ helpers.content_tag(:div, @rendered_content.html_safe, class: "panda-cms-content", **element_attrs)
281
+ end
250
282
  end
251
283
  end
252
284
  end
@@ -18,7 +18,13 @@ module Panda
18
18
  def view_template
19
19
  return unless @content
20
20
 
21
- span(**element_attrs) { raw(@content.html_safe) }
21
+ # Russian doll caching: Cache component output at block_content level
22
+ # Only cache in non-editable mode (public-facing pages)
23
+ if should_cache?
24
+ raw cache_component_output
25
+ else
26
+ render_content
27
+ end
22
28
  rescue => e
23
29
  handle_error(e)
24
30
  end
@@ -35,11 +41,11 @@ module Panda
35
41
  block = find_block
36
42
  return false if block.nil?
37
43
 
38
- block_content = find_block_content(block)
39
- @plain_text = block_content&.content.to_s
44
+ find_block_content(block)
45
+ @plain_text = @block_content_obj&.content.to_s
40
46
 
41
47
  if @editable_state
42
- setup_editable_content(block_content)
48
+ setup_editable_content(@block_content_obj)
43
49
  else
44
50
  @content = prepare_content_for_display(@plain_text)
45
51
  end
@@ -54,7 +60,7 @@ module Panda
54
60
  end
55
61
 
56
62
  def find_block_content(block)
57
- block.block_contents.find_by(panda_cms_page_id: Current.page.id)
63
+ @block_content_obj = block.block_contents.find_by(panda_cms_page_id: Current.page.id)
58
64
  end
59
65
 
60
66
  def setup_editable_content(block_content)
@@ -66,14 +72,12 @@ module Panda
66
72
  attrs = @attrs.merge(id: element_id)
67
73
 
68
74
  if @editable_state
69
- attrs.merge!(
70
- contenteditable: "plaintext-only",
71
- data: {
72
- "editable-kind": "plain_text",
73
- "editable-page-id": Current.page.id,
74
- "editable-block-content-id": @block_content_id
75
- }
76
- )
75
+ attrs[:contenteditable] = "plaintext-only"
76
+ attrs[:data] = {
77
+ "editable-kind": "plain_text",
78
+ "editable-page-id": Current.page.id,
79
+ "editable-block-content-id": @block_content_id
80
+ }
77
81
  end
78
82
 
79
83
  attrs
@@ -92,13 +96,41 @@ module Panda
92
96
  view_context.params[:embed_id].present? && view_context.params[:embed_id] == Current.page.id
93
97
  end
94
98
 
95
- def handle_error(error)
99
+ def handle_error(_error)
96
100
  if !Rails.env.production? || defined?(Sentry)
97
101
  raise Panda::CMS::MissingBlockError, "Block with key #{@key} not found for page #{Current.page.title}"
98
102
  end
99
103
 
100
104
  false
101
105
  end
106
+
107
+ def should_cache?
108
+ !@editable_state &&
109
+ Panda::CMS.config.performance.dig(:fragment_caching, :enabled) != false &&
110
+ @block_content_obj.present?
111
+ end
112
+
113
+ def cache_component_output
114
+ cache_key = cache_key_for_component
115
+ expires_in = Panda::CMS.config.performance.dig(:fragment_caching, :expires_in) || 1.hour
116
+
117
+ Rails.cache.fetch(cache_key, expires_in: expires_in) do
118
+ render_content_to_string
119
+ end.html_safe
120
+ end
121
+
122
+ def cache_key_for_component
123
+ "panda_cms/text_component/#{@block_content_obj.cache_key_with_version}/#{@key}"
124
+ end
125
+
126
+ def render_content
127
+ span(**element_attrs) { raw(@content.html_safe) }
128
+ end
129
+
130
+ def render_content_to_string
131
+ # Phlex doesn't have a direct way to capture output, so we render directly
132
+ helpers.content_tag(:span, @content.html_safe, **element_attrs)
133
+ end
102
134
  end
103
135
  end
104
136
  end
@@ -36,7 +36,7 @@ module Panda
36
36
  # @type GET
37
37
  def edit
38
38
  add_breadcrumb @menu.name, edit_admin_cms_menu_path(@menu)
39
- render :edit, locals: {menu: @menu}
39
+ render :edit
40
40
  end
41
41
 
42
42
  # @type PATCH/PUT
@@ -44,7 +44,7 @@ module Panda
44
44
  if @menu.update(menu_params)
45
45
  redirect_to admin_cms_menus_path, notice: "Menu was successfully updated."
46
46
  else
47
- render :edit, locals: {menu: @menu}, status: :unprocessable_entity
47
+ render :edit, status: :unprocessable_entity
48
48
  end
49
49
  end
50
50
 
@@ -66,7 +66,7 @@ module Panda
66
66
  flash: {success: "This page was successfully updated!"}
67
67
  else
68
68
  flash[:error] = "There was an error updating the page."
69
- render :edit, status: :unprocessable_entity
69
+ render :edit, locals: {page: page, template: page.template}, status: :unprocessable_entity
70
70
  end
71
71
  end
72
72
 
@@ -99,7 +99,11 @@ module Panda
99
99
  # @type private
100
100
  # @return ActionController::StrongParameters
101
101
  def page_params
102
- params.require(:page).permit(:title, :path, :panda_cms_template_id, :parent_id, :status, :page_type)
102
+ params.require(:page).permit(
103
+ :title, :path, :panda_cms_template_id, :parent_id, :status, :page_type,
104
+ :seo_title, :seo_description, :seo_keywords, :seo_index_mode, :canonical_url,
105
+ :og_title, :og_description, :og_type, :og_image, :inherit_seo
106
+ )
103
107
  end
104
108
  end
105
109
  end
@@ -119,7 +119,9 @@ module Panda
119
119
  :status,
120
120
  :published_at,
121
121
  :author_id,
122
- :content
122
+ :content,
123
+ :seo_title, :seo_description, :seo_keywords, :seo_index_mode, :canonical_url,
124
+ :og_title, :og_description, :og_type, :og_image
123
125
  )
124
126
  end
125
127
 
@@ -3,23 +3,146 @@
3
3
  module Panda
4
4
  module CMS
5
5
  class FormSubmissionsController < ApplicationController
6
- invisible_captcha only: [:create]
6
+ # Spam protection - invisible honeypot field
7
+ invisible_captcha only: [:create], on_spam: :log_spam
7
8
 
8
- def create
9
- vars = params.except(:authenticity_token, :controller, :action, :id)
9
+ # Rate limiting to prevent spam
10
+ before_action :check_rate_limit, only: [:create]
10
11
 
12
+ def create
11
13
  form = Panda::CMS::Form.find(params[:id])
12
- form_submission = Panda::CMS::FormSubmission.create(form_id: params[:id], data: vars.to_unsafe_h)
13
- form.update(submission_count: form.submission_count + 1)
14
14
 
15
- Panda::CMS::FormMailer.notification_email(form: form, form_submission: form_submission).deliver_now
15
+ # Additional spam checks
16
+ if looks_like_spam?(params)
17
+ log_spam_attempt(form, "content")
18
+ redirect_to_fallback(form, spam: true)
19
+ return
20
+ end
21
+
22
+ # Timing-based spam detection (honeypot timing)
23
+ if submitted_too_quickly?(params)
24
+ log_spam_attempt(form, "timing")
25
+ redirect_to_fallback(form, spam: true)
26
+ return
27
+ end
28
+
29
+ # Clean parameters - exclude system params and honeypot field
30
+ vars = params.except(:authenticity_token, :controller, :action, :id, :_form_timestamp, :spinner)
31
+
32
+ # Create submission
33
+ form_submission = Panda::CMS::FormSubmission.create!(
34
+ form_id: form.id,
35
+ data: vars.to_unsafe_h,
36
+ ip_address: request.remote_ip,
37
+ user_agent: request.user_agent
38
+ )
39
+
40
+ # Update submission count
41
+ form.increment!(:submission_count)
42
+
43
+ # Send notification email (in background if possible)
44
+ begin
45
+ Panda::CMS::FormMailer.notification_email(form: form, form_submission: form_submission).deliver_now
46
+ rescue => e
47
+ Rails.logger&.error "Failed to send form notification email: #{e.message}"
48
+ # Don't fail the submission if email fails
49
+ end
50
+
51
+ redirect_to_fallback(form, success: true)
52
+ rescue ActiveRecord::RecordInvalid => e
53
+ Rails.logger&.error "Form submission validation failed: #{e.message}"
54
+ redirect_to_fallback(form, error: true)
55
+ end
56
+
57
+ private
58
+
59
+ # Check for basic spam indicators
60
+ def looks_like_spam?(params)
61
+ # Check for too many URLs in message fields
62
+ message_fields = params.values.select { |v| v.is_a?(String) && v.length > 20 }
63
+ message_fields.any? { |field| field.scan(/https?:\/\//).length > 3 }
64
+ end
65
+
66
+ # Timing-based spam detection
67
+ # Rejects submissions that are too fast (< 3 seconds) or too stale (> 24 hours)
68
+ def submitted_too_quickly?(params)
69
+ return false unless params[:_form_timestamp].present?
70
+
71
+ begin
72
+ form_loaded_at = Time.zone.at(params[:_form_timestamp].to_i)
73
+ time_elapsed = Time.current - form_loaded_at
74
+
75
+ # Too fast - likely a bot (< 3 seconds)
76
+ if time_elapsed < 3.seconds
77
+ Rails.logger&.warn "Form submitted too quickly: #{time_elapsed.round(2)}s from IP: #{request.remote_ip}"
78
+ return true
79
+ end
80
+
81
+ # Too stale - form held too long without interaction (> 24 hours)
82
+ if time_elapsed > 24.hours
83
+ Rails.logger&.warn "Form submission too old: #{(time_elapsed / 1.hour).round(1)}h from IP: #{request.remote_ip}"
84
+ return true
85
+ end
86
+
87
+ false
88
+ rescue ArgumentError, TypeError => e
89
+ Rails.logger&.warn "Invalid form timestamp from IP #{request.remote_ip}: #{e.message}"
90
+ # Don't reject on invalid timestamp - might be legitimate user with modified form
91
+ false
92
+ end
93
+ end
94
+
95
+ # Rate limiting - max 3 submissions per IP per 5 minutes
96
+ def check_rate_limit
97
+ cache_key = "form_submission_rate_limit:#{request.remote_ip}"
98
+ count = Rails.cache.read(cache_key) || 0
99
+
100
+ if count >= 3
101
+ Rails.logger&.warn "Rate limit exceeded for IP: #{request.remote_ip}"
102
+ render plain: "Too many requests. Please try again later.", status: :too_many_requests
103
+ return
104
+ end
105
+
106
+ Rails.cache.write(cache_key, count + 1, expires_in: 5.minutes)
107
+ end
108
+
109
+ # Log spam attempt with reason
110
+ def log_spam_attempt(form, reason)
111
+ Rails.logger&.warn "Spam detected (#{reason}) for form #{form.id} from IP: #{request.remote_ip}"
112
+ end
113
+
114
+ # Callback for invisible_captcha spam detection
115
+ def log_spam
116
+ Rails.logger&.warn "Invisible captcha triggered from IP: #{request.remote_ip}"
117
+ end
118
+
119
+ # Safe redirect that works in engine context
120
+ def redirect_to_fallback(form, success: false, spam: false, error: false)
121
+ fallback = "/"
16
122
 
17
- if (completion_path = form&.completion_path)
18
- redirect_to completion_path
123
+ if spam
124
+ # Redirect to same page to appear successful (don't tell spammers)
125
+ redirect_back(fallback_location: fallback, allow_other_host: false)
126
+ elsif success && form.completion_path.present?
127
+ # Redirect to custom completion path
128
+ redirect_to form.completion_path, notice: "Thank you for your submission!"
129
+ elsif success
130
+ # Redirect back to referring page with success message
131
+ redirect_back(
132
+ fallback_location: fallback,
133
+ notice: "Thank you for your submission!",
134
+ allow_other_host: false
135
+ )
136
+ elsif error
137
+ # Redirect back with error message
138
+ redirect_back(
139
+ fallback_location: fallback,
140
+ alert: "There was an error submitting your form. Please try again.",
141
+ allow_other_host: false
142
+ )
19
143
  else
20
- # TODO: This isn't a great fallback, we should do something nice here...
21
- # Perhaps a simple JS alert when sent?
22
- redirect_to "/"
144
+ # Default fallback
145
+ redirect_back(fallback_location: fallback, allow_other_host: false)
23
146
  end
24
147
  end
25
148
  end
@@ -16,9 +16,9 @@ module Panda
16
16
 
17
17
  def show
18
18
  page = if @overrides&.dig(:page_path_match)
19
- Panda::CMS::Page.includes(:template).find_by(path: @overrides[:page_path_match])
19
+ Panda::CMS::Page.includes(:template, :block_contents).find_by(path: @overrides[:page_path_match])
20
20
  else
21
- Panda::CMS::Page.includes(:template).find_by(path: "/#{params[:path]}")
21
+ Panda::CMS::Page.includes(:template, :block_contents).find_by(path: "/#{params[:path]}")
22
22
  end
23
23
 
24
24
  Panda::CMS::Current.page = page || Panda::CMS::Page.find_by(path: "/404")
@@ -31,6 +31,11 @@ module Panda
31
31
  render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found and return
32
32
  end
33
33
 
34
+ # HTTP caching: Send ETag and Last-Modified headers for efficient caching
35
+ # Use cached_last_updated_at which includes block content updates
36
+ # Returns 304 Not Modified if client's cached version is still valid
37
+ fresh_when(page, last_modified: page.last_updated_at, public: true)
38
+
34
39
  template_vars = {
35
40
  page: page,
36
41
  title: Panda::CMS::Current.page&.title || Panda::CMS.config.title
@@ -7,6 +7,12 @@ module Panda
7
7
  # inside a /panda/cms/posts/... structure in the application
8
8
  def index
9
9
  @posts = Panda::CMS::Post.includes(:author).order(published_at: :desc)
10
+
11
+ # HTTP caching: Use the most recent post's updated_at for conditional requests
12
+ # Returns 304 Not Modified if no posts have changed since client's last request
13
+ latest_post_timestamp = @posts.maximum(:updated_at) || Time.current
14
+ fresh_when(etag: [@posts.to_a, latest_post_timestamp], last_modified: latest_post_timestamp, public: true)
15
+
10
16
  render inline: "", layout: Panda::CMS.config.posts[:layouts][:index]
11
17
  end
12
18
 
@@ -19,6 +25,11 @@ module Panda
19
25
  # For non-date URLs
20
26
  Panda::CMS::Post.find_by!(slug: "/#{params[:slug]}")
21
27
  end
28
+
29
+ # HTTP caching: Send ETag and Last-Modified headers for individual posts
30
+ # Returns 304 Not Modified if client's cached version is still valid
31
+ fresh_when(@post, last_modified: @post.updated_at, public: true)
32
+
22
33
  render inline: "", layout: Panda::CMS.config.posts[:layouts][:show]
23
34
  end
24
35
 
@@ -30,6 +41,11 @@ module Panda
30
41
  .includes(:author)
31
42
  .ordered
32
43
 
44
+ # HTTP caching: Use the most recent post in this month for conditional requests
45
+ # Returns 304 Not Modified if no posts in this month have changed
46
+ latest_month_timestamp = @posts.maximum(:updated_at) || @month
47
+ fresh_when(etag: [@posts.to_a, @month], last_modified: latest_month_timestamp, public: true)
48
+
33
49
  render inline: "", layout: Panda::CMS.config.posts[:layouts][:by_month]
34
50
  end
35
51
  end
@@ -2,8 +2,7 @@ module Panda
2
2
  module CMS
3
3
  module ApplicationHelper
4
4
  #
5
- # Helper method to render a ViewComponent
6
- # @see ViewComponent::Rendering#render
5
+ # Helper method to render a component
7
6
  # @usage <%= component "example", title: "Hello World!" %>
8
7
  #
9
8
  def component(name, *, **, &)
@@ -60,7 +59,7 @@ module Panda
60
59
 
61
60
  def panda_cms_form_with(**options, &)
62
61
  options[:builder] = Panda::Core::FormBuilder
63
- options[:class] = ["block visible p-6 bg-mid/5 rounded-lg border-mid border", options[:class]].compact.join(" ")
62
+ options[:class] = ["block visible px-4 sm:px-6 pt-4", options[:class]].compact.join(" ")
64
63
  form_with(**options, &)
65
64
  end
66
65