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.
- checksums.yaml +4 -4
- data/README.md +2 -11
- data/app/components/panda/cms/code_component.rb +45 -8
- data/app/components/panda/cms/menu_component.rb +9 -3
- data/app/components/panda/cms/page_menu_component.rb +9 -1
- data/app/components/panda/cms/rich_text_component.rb +49 -17
- data/app/components/panda/cms/text_component.rb +46 -14
- data/app/controllers/panda/cms/admin/menus_controller.rb +2 -2
- data/app/controllers/panda/cms/admin/pages_controller.rb +6 -2
- data/app/controllers/panda/cms/admin/posts_controller.rb +3 -1
- data/app/controllers/panda/cms/form_submissions_controller.rb +134 -11
- data/app/controllers/panda/cms/pages_controller.rb +7 -2
- data/app/controllers/panda/cms/posts_controller.rb +16 -0
- data/app/helpers/panda/cms/application_helper.rb +2 -3
- data/app/helpers/panda/cms/asset_helper.rb +14 -72
- data/app/helpers/panda/cms/forms_helper.rb +60 -0
- data/app/helpers/panda/cms/seo_helper.rb +85 -0
- data/app/javascript/panda/cms/{application_panda_cms.js → application.js} +4 -0
- data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
- data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/cms/controllers/index.js +6 -0
- data/app/javascript/panda/cms/controllers/menu_form_controller.js +14 -1
- data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
- data/app/javascript/panda/cms/stimulus-loading.js +2 -1
- data/app/models/panda/cms/menu.rb +12 -0
- data/app/models/panda/cms/page.rb +106 -0
- data/app/models/panda/cms/post.rb +97 -0
- data/app/views/layouts/homepage.html.erb +1 -4
- data/app/views/layouts/page.html.erb +1 -4
- data/app/views/panda/cms/admin/dashboard/show.html.erb +1 -1
- data/app/views/panda/cms/admin/files/index.html.erb +1 -1
- data/app/views/panda/cms/admin/forms/show.html.erb +3 -3
- data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +3 -3
- data/app/views/panda/cms/admin/menus/edit.html.erb +12 -14
- data/app/views/panda/cms/admin/menus/index.html.erb +1 -1
- data/app/views/panda/cms/admin/menus/new.html.erb +5 -7
- data/app/views/panda/cms/admin/pages/edit.html.erb +139 -20
- data/app/views/panda/cms/admin/pages/index.html.erb +6 -6
- data/app/views/panda/cms/admin/posts/_form.html.erb +41 -2
- data/app/views/panda/cms/admin/posts/edit.html.erb +1 -1
- data/app/views/panda/cms/admin/posts/index.html.erb +4 -4
- data/app/views/shared/_header.html.erb +1 -4
- data/config/brakeman.ignore +38 -0
- data/config/importmap.rb +8 -6
- data/config/locales/en.yml +41 -0
- data/config/routes.rb +1 -1
- data/db/migrate/20251109131150_add_seo_fields_to_pages.rb +32 -0
- data/db/migrate/20251109131205_add_seo_fields_to_posts.rb +27 -0
- data/db/migrate/20251110114258_add_spam_tracking_to_form_submissions.rb +7 -0
- data/db/migrate/20251110122812_add_performance_indexes_to_pages_and_redirects.rb +13 -0
- data/lib/panda/cms/asset_loader.rb +27 -77
- data/lib/panda/cms/bulk_editor.rb +288 -12
- data/lib/panda/cms/engine/asset_config.rb +49 -0
- data/lib/panda/cms/engine/autoload_config.rb +19 -0
- data/lib/panda/cms/engine/backtrace_config.rb +42 -0
- data/lib/panda/cms/engine/core_config.rb +106 -0
- data/lib/panda/cms/engine/helper_config.rb +20 -0
- data/lib/panda/cms/engine/route_config.rb +34 -0
- data/lib/panda/cms/engine/view_component_config.rb +31 -0
- data/lib/panda/cms/engine.rb +44 -221
- data/lib/panda/cms.rb +10 -0
- data/lib/panda-cms/version.rb +1 -1
- data/lib/panda-cms.rb +16 -2
- metadata +20 -22
- data/app/javascript/panda_cms/stimulus-loading.js +0 -39
- data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
- data/config/initializers/inflections.rb +0 -5
- data/lib/tasks/assets.rake +0 -540
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64a39a57fd00f6001e7992e634d5aef7b0536e7bfc4295399ea23737c10b0d83
|
|
4
|
+
data.tar.gz: 1b6d908a7f04e2f35ac7402b0c687160e71ac11e939a47f4193e1b4083947019
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
@code_content =
|
|
41
|
-
@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
|
-
#
|
|
127
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
data:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
@plain_text =
|
|
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(
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
@@ -3,23 +3,146 @@
|
|
|
3
3
|
module Panda
|
|
4
4
|
module CMS
|
|
5
5
|
class FormSubmissionsController < ApplicationController
|
|
6
|
-
|
|
6
|
+
# Spam protection - invisible honeypot field
|
|
7
|
+
invisible_captcha only: [:create], on_spam: :log_spam
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
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
|
-
#
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
|