panda-cms 0.8.2 → 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 +75 -5
- data/app/components/panda/cms/code_component.rb +154 -39
- data/app/components/panda/cms/grid_component.rb +26 -6
- data/app/components/panda/cms/menu_component.rb +72 -34
- data/app/components/panda/cms/page_menu_component.rb +102 -13
- data/app/components/panda/cms/rich_text_component.rb +229 -139
- data/app/components/panda/cms/text_component.rb +107 -42
- data/app/controllers/panda/cms/admin/base_controller.rb +19 -3
- data/app/controllers/panda/cms/admin/dashboard_controller.rb +3 -3
- data/app/controllers/panda/cms/admin/files_controller.rb +7 -0
- data/app/controllers/panda/cms/admin/menus_controller.rb +47 -3
- data/app/controllers/panda/cms/admin/pages_controller.rb +11 -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 +17 -4
- data/app/helpers/panda/cms/asset_helper.rb +14 -61
- 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} +5 -1
- data/app/javascript/panda/cms/controllers/code_editor_controller.js +95 -0
- data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +31 -4
- data/app/javascript/panda/cms/controllers/file_gallery_controller.js +128 -0
- data/app/javascript/panda/cms/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/cms/controllers/index.js +54 -13
- data/app/javascript/panda/cms/controllers/inline_code_editor_controller.js +96 -0
- data/app/javascript/panda/cms/controllers/menu_form_controller.js +53 -0
- data/app/javascript/panda/cms/controllers/nested_form_controller.js +35 -0
- data/app/javascript/panda/cms/controllers/page_form_controller.js +454 -0
- data/app/javascript/panda/cms/controllers/tree_controller.js +214 -0
- data/app/javascript/panda/cms/stimulus-loading.js +6 -7
- data/app/models/panda/cms/block_content.rb +9 -0
- data/app/models/panda/cms/menu.rb +12 -0
- data/app/models/panda/cms/page.rb +147 -0
- data/app/models/panda/cms/post.rb +98 -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 +5 -5
- data/app/views/panda/cms/admin/files/_file_details.html.erb +45 -0
- data/app/views/panda/cms/admin/files/index.html.erb +11 -118
- data/app/views/panda/cms/admin/forms/index.html.erb +2 -2
- data/app/views/panda/cms/admin/forms/new.html.erb +1 -2
- data/app/views/panda/cms/admin/forms/show.html.erb +15 -30
- data/app/views/panda/cms/admin/menus/_menu_item_fields.html.erb +11 -0
- data/app/views/panda/cms/admin/menus/edit.html.erb +62 -0
- data/app/views/panda/cms/admin/menus/index.html.erb +3 -2
- data/app/views/panda/cms/admin/menus/new.html.erb +38 -0
- data/app/views/panda/cms/admin/pages/edit.html.erb +147 -22
- data/app/views/panda/cms/admin/pages/index.html.erb +49 -11
- data/app/views/panda/cms/admin/pages/new.html.erb +3 -11
- data/app/views/panda/cms/admin/posts/_form.html.erb +44 -15
- data/app/views/panda/cms/admin/posts/edit.html.erb +2 -2
- data/app/views/panda/cms/admin/posts/index.html.erb +6 -6
- data/app/views/panda/cms/admin/posts/new.html.erb +1 -1
- data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +1 -1
- data/app/views/panda/cms/admin/settings/index.html.erb +3 -3
- data/app/views/shared/_header.html.erb +1 -4
- data/config/brakeman.ignore +38 -0
- data/config/importmap.rb +10 -10
- data/config/initializers/panda/cms/healthcheck_log_silencer.rb.disabled +31 -0
- data/config/initializers/panda/cms.rb +52 -10
- data/config/locales/en.yml +41 -0
- data/config/routes.rb +5 -3
- data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +2 -2
- data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +6 -1
- data/db/migrate/20250809231125_migrate_users_to_panda_core.rb +23 -21
- data/db/migrate/20251104150640_add_cached_last_updated_at_to_panda_cms_pages.rb +22 -0
- data/db/migrate/20251104172242_add_page_type_to_panda_cms_pages.rb +6 -0
- data/db/migrate/20251104172638_set_page_types_for_existing_pages.rb +27 -0
- data/db/migrate/20251105000001_add_pending_review_status_to_pages_and_posts.panda_cms.rb +21 -0
- 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/generators/panda/cms/install_generator.rb +2 -5
- data/lib/panda/cms/asset_loader.rb +46 -76
- data/lib/panda/cms/bulk_editor.rb +288 -12
- data/lib/panda/cms/debug.rb +29 -0
- 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 -162
- data/lib/panda/cms/features.rb +52 -0
- data/lib/panda/cms.rb +10 -0
- data/lib/panda-cms/version.rb +1 -1
- data/lib/panda-cms.rb +20 -7
- data/lib/tasks/panda_cms_tasks.rake +16 -0
- metadata +41 -50
- data/app/components/panda/cms/admin/container_component.html.erb +0 -13
- data/app/components/panda/cms/admin/flash_message_component.html.erb +0 -31
- data/app/components/panda/cms/admin/panel_component.html.erb +0 -7
- data/app/components/panda/cms/admin/slideover_component.html.erb +0 -9
- data/app/components/panda/cms/admin/slideover_component.rb +0 -15
- data/app/components/panda/cms/admin/statistics_component.html.erb +0 -4
- data/app/components/panda/cms/admin/statistics_component.rb +0 -16
- data/app/components/panda/cms/admin/tab_bar_component.html.erb +0 -35
- data/app/components/panda/cms/admin/tab_bar_component.rb +0 -15
- data/app/components/panda/cms/admin/table_component.html.erb +0 -29
- data/app/components/panda/cms/admin/user_activity_component.html.erb +0 -7
- data/app/components/panda/cms/admin/user_activity_component.rb +0 -20
- data/app/components/panda/cms/admin/user_display_component.html.erb +0 -17
- data/app/components/panda/cms/admin/user_display_component.rb +0 -21
- data/app/components/panda/cms/grid_component.html.erb +0 -6
- data/app/components/panda/cms/menu_component.html.erb +0 -6
- data/app/components/panda/cms/page_menu_component.html.erb +0 -21
- data/app/components/panda/cms/rich_text_component.html.erb +0 -90
- data/app/javascript/panda_cms/stimulus-loading.js +0 -39
- data/app/views/layouts/panda/cms/application.html.erb +0 -42
- data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +0 -28
- data/app/views/panda/cms/admin/shared/_flash.html.erb +0 -5
- data/app/views/panda/cms/admin/shared/_sidebar.html.erb +0 -41
- data/app/views/panda/cms/shared/_footer.html.erb +0 -2
- data/app/views/panda/cms/shared/_header.html.erb +0 -25
- data/app/views/panda/cms/shared/_importmap.html.erb +0 -34
- data/config/initializers/inflections.rb +0 -5
- data/config/initializers/panda/cms/healthcheck_log_silencer.rb +0 -13
- data/lib/tasks/assets.rake +0 -587
|
@@ -4,7 +4,8 @@ module Panda
|
|
|
4
4
|
module CMS
|
|
5
5
|
module Admin
|
|
6
6
|
class MenusController < ::Panda::CMS::Admin::BaseController
|
|
7
|
-
before_action :set_initial_breadcrumb, only: %i[index]
|
|
7
|
+
before_action :set_initial_breadcrumb, only: %i[index new edit]
|
|
8
|
+
before_action :set_menu, only: %i[edit update destroy]
|
|
8
9
|
|
|
9
10
|
# Lists all menus which can be managed by the administrator
|
|
10
11
|
# @type GET
|
|
@@ -14,10 +15,53 @@ module Panda
|
|
|
14
15
|
render :index, locals: {menus: menus}
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
# @type GET
|
|
19
|
+
def new
|
|
20
|
+
menu = Panda::CMS::Menu.new
|
|
21
|
+
add_breadcrumb "New Menu", new_admin_cms_menu_path
|
|
22
|
+
render :new, locals: {menu: menu}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @type POST
|
|
26
|
+
def create
|
|
27
|
+
menu = Panda::CMS::Menu.new(menu_params)
|
|
28
|
+
|
|
29
|
+
if menu.save
|
|
30
|
+
redirect_to admin_cms_menus_path, notice: "Menu was successfully created."
|
|
31
|
+
else
|
|
32
|
+
render :new, locals: {menu: menu}, status: :unprocessable_entity
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @type GET
|
|
37
|
+
def edit
|
|
38
|
+
add_breadcrumb @menu.name, edit_admin_cms_menu_path(@menu)
|
|
39
|
+
render :edit
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @type PATCH/PUT
|
|
43
|
+
def update
|
|
44
|
+
if @menu.update(menu_params)
|
|
45
|
+
redirect_to admin_cms_menus_path, notice: "Menu was successfully updated."
|
|
46
|
+
else
|
|
47
|
+
render :edit, status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @type DELETE
|
|
52
|
+
def destroy
|
|
53
|
+
@menu.destroy
|
|
54
|
+
redirect_to admin_cms_menus_path, notice: "Menu was successfully deleted."
|
|
55
|
+
end
|
|
56
|
+
|
|
17
57
|
private
|
|
18
58
|
|
|
19
|
-
def
|
|
20
|
-
@menu
|
|
59
|
+
def set_menu
|
|
60
|
+
@menu = Panda::CMS::Menu.find(params[:id])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def menu_params
|
|
64
|
+
params.require(:menu).permit(:name, :kind, :start_page_id, menu_items_attributes: [:id, :text, :external_url, :panda_cms_page_id, :_destroy])
|
|
21
65
|
end
|
|
22
66
|
|
|
23
67
|
def set_initial_breadcrumb
|
|
@@ -25,6 +25,11 @@ module Panda
|
|
|
25
25
|
# Loads the page editor
|
|
26
26
|
# @type GET
|
|
27
27
|
def edit
|
|
28
|
+
# Add all ancestor pages to breadcrumbs (excluding homepage at depth 0)
|
|
29
|
+
page.ancestors.select { |anc| anc.depth > 0 }.each do |ancestor|
|
|
30
|
+
add_breadcrumb ancestor.title, edit_admin_cms_page_path(ancestor)
|
|
31
|
+
end
|
|
32
|
+
|
|
28
33
|
add_breadcrumb page.title, edit_admin_cms_page_path(page)
|
|
29
34
|
|
|
30
35
|
render :edit, locals: {page: page, template: page.template}
|
|
@@ -61,7 +66,7 @@ module Panda
|
|
|
61
66
|
flash: {success: "This page was successfully updated!"}
|
|
62
67
|
else
|
|
63
68
|
flash[:error] = "There was an error updating the page."
|
|
64
|
-
render :edit, status: :unprocessable_entity
|
|
69
|
+
render :edit, locals: {page: page, template: page.template}, status: :unprocessable_entity
|
|
65
70
|
end
|
|
66
71
|
end
|
|
67
72
|
|
|
@@ -94,7 +99,11 @@ module Panda
|
|
|
94
99
|
# @type private
|
|
95
100
|
# @return ActionController::StrongParameters
|
|
96
101
|
def page_params
|
|
97
|
-
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
|
+
)
|
|
98
107
|
end
|
|
99
108
|
end
|
|
100
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.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.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, *, **, &)
|
|
@@ -33,7 +32,7 @@ module Panda
|
|
|
33
32
|
if match == :starts_with
|
|
34
33
|
return request.path.starts_with?(path)
|
|
35
34
|
elsif match == :exact
|
|
36
|
-
return
|
|
35
|
+
return request.path == path
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
false
|
|
@@ -44,9 +43,23 @@ module Panda
|
|
|
44
43
|
link_to(name, options, html_options, &)
|
|
45
44
|
end
|
|
46
45
|
|
|
46
|
+
def panda_cms_collection(slug, include_unpublished: false)
|
|
47
|
+
Panda::CMS::Features.require!(:collections)
|
|
48
|
+
Panda::CMS::Collections.fetch(slug, include_unpublished: include_unpublished)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def panda_cms_collection_items(slug, include_unpublished: false)
|
|
52
|
+
Panda::CMS::Features.require!(:collections)
|
|
53
|
+
Panda::CMS::Collections.items(slug, include_unpublished: include_unpublished)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def panda_cms_feature_enabled?(name)
|
|
57
|
+
Panda::CMS::Features.enabled?(name)
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
def panda_cms_form_with(**options, &)
|
|
48
61
|
options[:builder] = Panda::Core::FormBuilder
|
|
49
|
-
options[:class] = ["block visible
|
|
62
|
+
options[:class] = ["block visible px-4 sm:px-6 pt-4", options[:class]].compact.join(" ")
|
|
50
63
|
form_with(**options, &)
|
|
51
64
|
end
|
|
52
65
|
|
|
@@ -24,40 +24,9 @@ module Panda
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Include only Panda CMS JavaScript
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if Panda::CMS::AssetLoader.use_github_assets?
|
|
32
|
-
# GitHub-hosted assets with integrity check
|
|
33
|
-
version = Panda::CMS::AssetLoader.send(:asset_version)
|
|
34
|
-
integrity = asset_integrity(version, "panda-cms-#{version}.js")
|
|
35
|
-
|
|
36
|
-
tag_options = {
|
|
37
|
-
src: js_url
|
|
38
|
-
}
|
|
39
|
-
# In CI environment, don't use defer to ensure immediate execution
|
|
40
|
-
tag_options[:defer] = true unless ENV["GITHUB_ACTIONS"] == "true"
|
|
41
|
-
# Standalone bundles should NOT use type: "module" - they're regular scripts
|
|
42
|
-
# Only use type: "module" for importmap/ES module assets
|
|
43
|
-
if !js_url.include?("panda-cms-assets")
|
|
44
|
-
tag_options[:type] = "module"
|
|
45
|
-
end
|
|
46
|
-
tag_options[:integrity] = integrity if integrity
|
|
47
|
-
tag_options[:crossorigin] = "anonymous" if integrity
|
|
48
|
-
|
|
49
|
-
content_tag(:script, "", tag_options)
|
|
50
|
-
elsif js_url.include?("panda-cms-assets")
|
|
51
|
-
# Development assets - check if it's a standalone bundle or importmap
|
|
52
|
-
defer_option = (ENV["GITHUB_ACTIONS"] == "true") ? {} : {defer: true}
|
|
53
|
-
javascript_include_tag(js_url, **defer_option)
|
|
54
|
-
# Standalone bundle - don't use type: "module"
|
|
55
|
-
else
|
|
56
|
-
# Importmap asset - use type: "module"
|
|
57
|
-
defer_option = (ENV["GITHUB_ACTIONS"] == "true") ? {} : {defer: true}
|
|
58
|
-
javascript_include_tag(js_url, type: "module", **defer_option)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
27
|
+
#
|
|
28
|
+
# Delegates to Core's helper which automatically includes all registered modules.
|
|
29
|
+
alias_method :panda_cms_javascript, :panda_core_javascript
|
|
61
30
|
|
|
62
31
|
# Include only Panda CMS CSS
|
|
63
32
|
def panda_cms_stylesheet
|
|
@@ -106,39 +75,16 @@ module Panda
|
|
|
106
75
|
version = Panda::CMS::VERSION
|
|
107
76
|
js_url = Panda::CMS::AssetLoader.javascript_url
|
|
108
77
|
css_url = Panda::CMS::AssetLoader.css_url
|
|
109
|
-
|
|
110
|
-
compiled_available = Panda::CMS::AssetLoader.send(:compiled_assets_available?)
|
|
111
|
-
|
|
112
|
-
# Additional CI debugging
|
|
113
|
-
asset_file_exists = js_url && File.exist?(Rails.root.join("public#{js_url}"))
|
|
114
|
-
ci_env = ENV["GITHUB_ACTIONS"] == "true"
|
|
115
|
-
|
|
116
|
-
# Check what script tag will be generated
|
|
117
|
-
script_tag_preview = if using_github
|
|
118
|
-
tag_options = {src: js_url}
|
|
119
|
-
tag_options[:defer] = true unless ci_env
|
|
120
|
-
if !js_url.include?("panda-cms-assets")
|
|
121
|
-
tag_options[:type] = "module"
|
|
122
|
-
end
|
|
123
|
-
"Script tag: <script#{tag_options.map { |k, v| (v == true) ? " #{k}" : " #{k}=\"#{v}\"" }.join}></script>"
|
|
124
|
-
else
|
|
125
|
-
"Using development assets"
|
|
126
|
-
end
|
|
78
|
+
Panda::CMS::AssetLoader.use_github_assets?
|
|
127
79
|
|
|
128
80
|
debug_info = [
|
|
129
81
|
"<!-- Panda CMS Asset Debug Info -->",
|
|
130
82
|
"<!-- Version: #{version} -->",
|
|
131
|
-
"<!-- Using
|
|
132
|
-
"<!-- Compiled assets available: #{compiled_available} -->",
|
|
83
|
+
"<!-- Using importmaps: true (no compilation) -->",
|
|
133
84
|
"<!-- JavaScript URL: #{js_url} -->",
|
|
134
|
-
"<!-- CSS URL: #{css_url || "
|
|
85
|
+
"<!-- CSS URL: #{css_url || "CSS from panda-core"} -->",
|
|
135
86
|
"<!-- Rails environment: #{Rails.env} -->",
|
|
136
|
-
"<!--
|
|
137
|
-
"<!-- Rails root: #{Rails.root} -->",
|
|
138
|
-
"<!-- CI environment: #{ci_env} -->",
|
|
139
|
-
"<!-- #{script_tag_preview} -->",
|
|
140
|
-
"<!-- Params embed_id: #{params[:embed_id] if respond_to?(:params)} -->",
|
|
141
|
-
"<!-- Compiled at: #{Time.now.utc.iso8601} -->"
|
|
87
|
+
"<!-- Rails root: #{Rails.root} -->"
|
|
142
88
|
]
|
|
143
89
|
|
|
144
90
|
debug_info.join("\n").html_safe
|
|
@@ -183,6 +129,13 @@ module Panda
|
|
|
183
129
|
].join("\n").html_safe
|
|
184
130
|
end
|
|
185
131
|
|
|
132
|
+
# Returns asset HTML for injection into iframe
|
|
133
|
+
# Used by editor_iframe_controller to inject assets dynamically
|
|
134
|
+
# Returns the raw HTML string (not JSON-encoded)
|
|
135
|
+
def panda_cms_injectable_assets
|
|
136
|
+
panda_cms_complete_assets.to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
186
139
|
private
|
|
187
140
|
|
|
188
141
|
def asset_integrity(version, filename)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module CMS
|
|
5
|
+
module FormsHelper
|
|
6
|
+
# Generates a hidden timing field for spam protection
|
|
7
|
+
# This should be included in all forms that submit to Panda::CMS::FormSubmissionsController
|
|
8
|
+
#
|
|
9
|
+
# @example In your form
|
|
10
|
+
# <%= form_with url: form_submissions_path(form.id), method: :post do |f| %>
|
|
11
|
+
# <%= panda_cms_form_timestamp %>
|
|
12
|
+
# <%= f.text_field :name %>
|
|
13
|
+
# <%= f.submit "Submit" %>
|
|
14
|
+
# <% end %>
|
|
15
|
+
#
|
|
16
|
+
# @return [String] HTML hidden input with current timestamp
|
|
17
|
+
def panda_cms_form_timestamp
|
|
18
|
+
hidden_field_tag "_form_timestamp", Time.current.to_i
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Generates a complete spam-protected form wrapper
|
|
22
|
+
# Includes timing protection and invisible captcha honeypot
|
|
23
|
+
#
|
|
24
|
+
# @param form [Panda::CMS::Form] The form model
|
|
25
|
+
# @param options [Hash] Additional options for form_with
|
|
26
|
+
# @yield [FormBuilder] The form builder
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# <%= panda_cms_protected_form(form) do |f| %>
|
|
30
|
+
# <%= f.text_field :name %>
|
|
31
|
+
# <%= f.email_field :email %>
|
|
32
|
+
# <%= f.text_area :message %>
|
|
33
|
+
# <%= f.submit "Send Message" %>
|
|
34
|
+
# <% end %>
|
|
35
|
+
def panda_cms_protected_form(form, options = {}, &block)
|
|
36
|
+
default_options = {
|
|
37
|
+
url: "/forms/#{form.id}",
|
|
38
|
+
method: :post,
|
|
39
|
+
data: {turbo: false}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
form_with(**default_options.merge(options)) do |f|
|
|
43
|
+
concat panda_cms_form_timestamp
|
|
44
|
+
concat invisible_captcha_field
|
|
45
|
+
yield f
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Generates the invisible captcha honeypot field
|
|
50
|
+
# This is a hidden field that bots typically fill out but humans don't
|
|
51
|
+
#
|
|
52
|
+
# @return [String] HTML for invisible captcha field
|
|
53
|
+
def invisible_captcha_field
|
|
54
|
+
# invisible_captcha gem automatically adds this, but we can add it manually if needed
|
|
55
|
+
# The field name "spinner" is configured in invisible_captcha initializer
|
|
56
|
+
text_field_tag :spinner, nil, style: "position: absolute; left: -9999px; width: 1px; height: 1px;", tabindex: -1, autocomplete: "off", aria_hidden: true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module CMS
|
|
5
|
+
module SEOHelper
|
|
6
|
+
#
|
|
7
|
+
# Renders all SEO meta tags for a given page or post
|
|
8
|
+
#
|
|
9
|
+
# @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post to render meta tags for
|
|
10
|
+
# @return [String] HTML meta tags
|
|
11
|
+
# @visibility public
|
|
12
|
+
#
|
|
13
|
+
def render_seo_meta_tags(resource)
|
|
14
|
+
return "" if resource.blank?
|
|
15
|
+
|
|
16
|
+
tags = []
|
|
17
|
+
|
|
18
|
+
# Basic SEO tags
|
|
19
|
+
tags << tag.meta(name: "description", content: resource.effective_seo_description) if resource.effective_seo_description.present?
|
|
20
|
+
tags << tag.meta(name: "keywords", content: resource.seo_keywords) if resource.seo_keywords.present?
|
|
21
|
+
tags << tag.meta(name: "robots", content: resource.robots_meta_content)
|
|
22
|
+
tags << tag.link(rel: "canonical", href: canonical_url_for(resource))
|
|
23
|
+
|
|
24
|
+
# Open Graph tags
|
|
25
|
+
tags << tag.meta(property: "og:title", content: resource.effective_og_title)
|
|
26
|
+
tags << tag.meta(property: "og:description", content: resource.effective_og_description) if resource.effective_og_description.present?
|
|
27
|
+
tags << tag.meta(property: "og:type", content: resource.og_type)
|
|
28
|
+
tags << tag.meta(property: "og:url", content: canonical_url_for(resource))
|
|
29
|
+
|
|
30
|
+
# Open Graph image
|
|
31
|
+
if resource.og_image.attached?
|
|
32
|
+
og_image_url = url_for(resource.og_image.variant(:og_share))
|
|
33
|
+
tags << tag.meta(property: "og:image", content: og_image_url)
|
|
34
|
+
tags << tag.meta(property: "og:image:width", content: "1200")
|
|
35
|
+
tags << tag.meta(property: "og:image:height", content: "630")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Twitter Card tags (with fallback to OG)
|
|
39
|
+
tags << tag.meta(name: "twitter:card", content: "summary_large_image")
|
|
40
|
+
tags << tag.meta(name: "twitter:title", content: resource.effective_og_title)
|
|
41
|
+
tags << tag.meta(name: "twitter:description", content: resource.effective_og_description) if resource.effective_og_description.present?
|
|
42
|
+
|
|
43
|
+
# Twitter image (same as OG)
|
|
44
|
+
if resource.og_image.attached?
|
|
45
|
+
tags << tag.meta(name: "twitter:image", content: url_for(resource.og_image.variant(:og_share)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
safe_join(tags, "\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#
|
|
52
|
+
# Renders just the page title with SEO optimization
|
|
53
|
+
#
|
|
54
|
+
# @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post
|
|
55
|
+
# @param separator [String] Separator between page title and site name
|
|
56
|
+
# @param site_name [String] The site name (optional)
|
|
57
|
+
# @return [String] Formatted page title
|
|
58
|
+
# @visibility public
|
|
59
|
+
#
|
|
60
|
+
def seo_title(resource, separator: " · ", site_name: nil)
|
|
61
|
+
parts = [resource.effective_seo_title]
|
|
62
|
+
parts << site_name if site_name.present?
|
|
63
|
+
safe_join(parts, separator)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
#
|
|
69
|
+
# Generates the full canonical URL for a resource
|
|
70
|
+
#
|
|
71
|
+
# @param resource [Panda::CMS::Page, Panda::CMS::Post] The page or post
|
|
72
|
+
# @return [String] Full canonical URL
|
|
73
|
+
# @visibility private
|
|
74
|
+
#
|
|
75
|
+
def canonical_url_for(resource)
|
|
76
|
+
# If canonical_url is a full URL, use it as-is
|
|
77
|
+
return resource.canonical_url if resource.canonical_url&.match?(%r{\Ahttps?://})
|
|
78
|
+
|
|
79
|
+
# Otherwise, construct from the path
|
|
80
|
+
path = resource.effective_canonical_url
|
|
81
|
+
"#{request.protocol}#{request.host_with_port}#{path}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import "@hotwired/turbo"
|
|
2
2
|
console.debug("[Panda CMS] Controllers loading...");
|
|
3
|
-
import "controllers"
|
|
3
|
+
import "./controllers/index.js"
|
|
4
4
|
console.debug("[Panda CMS] Controllers loaded...");
|
|
5
5
|
|
|
6
|
+
// Mark that Panda CMS JavaScript has loaded
|
|
7
|
+
window.pandaCmsLoaded = true
|
|
8
|
+
console.debug("[Panda CMS] Ready!");
|
|
9
|
+
|
|
6
10
|
// Editor resources are now handled by panda-editor gem
|
|
7
11
|
// The panda-editor gem will load its own resources when needed
|