camaleon_cms 2.9.2 → 2.9.3

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 (211) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -4
  3. data/Rakefile +3 -16
  4. data/app/apps/plugins/attack/admin_controller.rb +9 -6
  5. data/app/apps/plugins/attack/attack_helper.rb +35 -25
  6. data/app/apps/plugins/authoring_post/authoring_post_helper.rb +23 -16
  7. data/app/apps/plugins/front_cache/admin_controller.rb +15 -10
  8. data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
  9. data/app/apps/plugins/front_cache/front_cache_helper.rb +33 -9
  10. data/app/apps/plugins/visibility_post/config/config.json +1 -0
  11. data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
  12. data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
  13. data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
  14. data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
  15. data/app/apps/themes/default/assets/css/style.css.scss +24 -0
  16. data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
  17. data/app/apps/themes/default/default_helper.rb +2 -1
  18. data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
  19. data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
  20. data/app/apps/themes/new/custom_helper.rb +5 -9
  21. data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
  22. data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
  23. data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
  24. data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
  25. data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
  26. data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +8 -6
  27. data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
  28. data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +7 -2
  29. data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +2 -1
  30. data/app/controllers/camaleon_cms/admin/categories_controller.rb +19 -4
  31. data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
  32. data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
  33. data/app/controllers/camaleon_cms/admin/media_controller.rb +66 -14
  34. data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
  35. data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +10 -4
  36. data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
  37. data/app/controllers/camaleon_cms/admin/posts_controller.rb +55 -22
  38. data/app/controllers/camaleon_cms/admin/sessions_controller.rb +237 -44
  39. data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +76 -13
  40. data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +5 -2
  41. data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +26 -3
  42. data/app/controllers/camaleon_cms/admin/settings_controller.rb +5 -3
  43. data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +7 -5
  44. data/app/controllers/camaleon_cms/admin/users_controller.rb +78 -11
  45. data/app/controllers/camaleon_cms/admin_controller.rb +136 -26
  46. data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
  47. data/app/controllers/camaleon_cms/frontend_controller.rb +140 -70
  48. data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +20 -5
  49. data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
  50. data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
  51. data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
  52. data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
  53. data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
  54. data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
  55. data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
  56. data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
  57. data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
  58. data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
  59. data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
  60. data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
  61. data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
  62. data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
  63. data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
  64. data/app/decorators/camaleon_cms/post_decorator.rb +66 -50
  65. data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
  66. data/app/decorators/camaleon_cms/site_decorator.rb +29 -23
  67. data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
  68. data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
  69. data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
  70. data/app/helpers/camaleon_cms/admin/application_helper.rb +5 -8
  71. data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
  72. data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
  73. data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
  74. data/app/helpers/camaleon_cms/admin/post_type_helper.rb +34 -17
  75. data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
  76. data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
  77. data/app/helpers/camaleon_cms/comment_helper.rb +19 -7
  78. data/app/helpers/camaleon_cms/content_helper.rb +13 -6
  79. data/app/helpers/camaleon_cms/email_helper.rb +7 -3
  80. data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
  81. data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +143 -66
  82. data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
  83. data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
  84. data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
  85. data/app/helpers/camaleon_cms/html_helper.rb +58 -37
  86. data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
  87. data/app/helpers/camaleon_cms/session_helper.rb +188 -44
  88. data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
  89. data/app/helpers/camaleon_cms/site_helper.rb +34 -13
  90. data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
  91. data/app/helpers/camaleon_cms/uploader_helper.rb +19 -473
  92. data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
  93. data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
  94. data/app/models/camaleon_cms/ability.rb +15 -7
  95. data/app/models/camaleon_cms/category.rb +6 -6
  96. data/app/models/camaleon_cms/custom_field.rb +15 -6
  97. data/app/models/camaleon_cms/custom_field_group.rb +39 -22
  98. data/app/models/camaleon_cms/custom_fields_relationship.rb +133 -2
  99. data/app/models/camaleon_cms/media.rb +8 -2
  100. data/app/models/camaleon_cms/meta.rb +2 -2
  101. data/app/models/camaleon_cms/nav_menu.rb +5 -3
  102. data/app/models/camaleon_cms/nav_menu_item.rb +16 -8
  103. data/app/models/camaleon_cms/plugin.rb +3 -4
  104. data/app/models/camaleon_cms/post.rb +113 -27
  105. data/app/models/camaleon_cms/post_comment.rb +10 -6
  106. data/app/models/camaleon_cms/post_default.rb +42 -12
  107. data/app/models/camaleon_cms/post_relationship.rb +6 -4
  108. data/app/models/camaleon_cms/post_tag.rb +4 -5
  109. data/app/models/camaleon_cms/post_type.rb +21 -13
  110. data/app/models/camaleon_cms/site.rb +39 -29
  111. data/app/models/camaleon_cms/term_relationship.rb +10 -7
  112. data/app/models/camaleon_cms/term_taxonomy.rb +52 -4
  113. data/app/models/camaleon_cms/theme.rb +2 -4
  114. data/app/models/camaleon_cms/user.rb +17 -5
  115. data/app/models/camaleon_cms/user_role.rb +66 -6
  116. data/app/models/camaleon_cms/widget/assigned.rb +11 -6
  117. data/app/models/camaleon_cms/widget/main.rb +12 -8
  118. data/app/models/camaleon_cms/widget/sidebar.rb +3 -8
  119. data/app/models/camaleon_record.rb +89 -5
  120. data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +20 -38
  121. data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
  122. data/app/models/concerns/camaleon_cms/custom_fields_read.rb +111 -54
  123. data/app/models/concerns/camaleon_cms/metas.rb +31 -6
  124. data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
  125. data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
  126. data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
  127. data/app/models/current_request.rb +8 -1
  128. data/app/uploaders/camaleon_cms_aws_uploader.rb +31 -10
  129. data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
  130. data/app/uploaders/camaleon_cms_uploader.rb +23 -10
  131. data/app/validators/camaleon_cms/post_uniq_validator.rb +5 -4
  132. data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
  133. data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
  134. data/app/views/camaleon_cms/admin/categories/index.html.erb +2 -2
  135. data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
  136. data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
  137. data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
  138. data/app/views/camaleon_cms/admin/post_tags/index.html.erb +2 -2
  139. data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +1 -1
  140. data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
  141. data/app/views/camaleon_cms/admin/posts/index.html.erb +5 -5
  142. data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
  143. data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
  144. data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
  145. data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
  146. data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +19 -4
  147. data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
  148. data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +4 -3
  149. data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
  150. data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
  151. data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
  152. data/app/views/camaleon_cms/admin/user_roles/form.html.erb +24 -3
  153. data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
  154. data/app/views/camaleon_cms/admin/users/index.html.erb +1 -1
  155. data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
  156. data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
  157. data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
  158. data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
  159. data/config/initializers/action_view.rb +42 -5
  160. data/config/initializers/active_record_extension.rb +13 -27
  161. data/config/initializers/assets.rb +52 -23
  162. data/config/initializers/custom_initializers.rb +1 -1
  163. data/config/locales/camaleon_cms/admin/ar.yml +1 -0
  164. data/config/locales/camaleon_cms/admin/de.yml +1 -0
  165. data/config/locales/camaleon_cms/admin/en.yml +33 -0
  166. data/config/locales/camaleon_cms/admin/es.yml +1 -0
  167. data/config/locales/camaleon_cms/admin/fr.yml +1 -0
  168. data/config/locales/camaleon_cms/admin/it.yml +1 -0
  169. data/config/locales/camaleon_cms/admin/nl.yml +1 -0
  170. data/config/locales/camaleon_cms/admin/pt-BR.yml +1 -0
  171. data/config/locales/camaleon_cms/admin/pt.yml +1 -0
  172. data/config/locales/camaleon_cms/admin/ru.yml +1 -0
  173. data/config/locales/camaleon_cms/admin/uk.yml +1 -0
  174. data/config/locales/camaleon_cms/admin/zh-CH.yml +1 -0
  175. data/config/routes/admin.rb +33 -15
  176. data/config/routes/frontend.rb +21 -15
  177. data/lib/camaleon_cms/assets_precompile.rb +86 -0
  178. data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
  179. data/lib/camaleon_cms/content_security.rb +158 -0
  180. data/lib/camaleon_cms/engine.rb +47 -9
  181. data/lib/camaleon_cms/media_security_headers.rb +27 -0
  182. data/lib/camaleon_cms/setup_token.rb +69 -0
  183. data/lib/camaleon_cms/svg_content_checker.rb +123 -0
  184. data/lib/camaleon_cms/task_reporter.rb +14 -0
  185. data/lib/camaleon_cms/unsafe_markup.rb +207 -0
  186. data/lib/camaleon_cms/uploader_content_security.rb +248 -0
  187. data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
  188. data/lib/camaleon_cms/uploader_path_security.rb +146 -0
  189. data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
  190. data/lib/camaleon_cms/uploader_support.rb +86 -0
  191. data/lib/camaleon_cms/version.rb +1 -1
  192. data/lib/camaleon_cms.rb +11 -0
  193. data/lib/ext/hash.rb +28 -2
  194. data/lib/ext/string.rb +10 -5
  195. data/lib/ext/translator.rb +10 -2
  196. data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
  197. data/lib/generators/camaleon_cms/install_generator.rb +7 -4
  198. data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
  199. data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
  200. data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
  201. data/lib/plugin_routes.rb +412 -368
  202. data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
  203. data/lib/tasks/cross_site_field_groups.rake +74 -0
  204. data/lib/tasks/custom_fields_roles.rake +15 -13
  205. data/lib/tasks/orphaned_comments.rake +42 -0
  206. data/lib/tasks/private_upload_acls.rake +31 -0
  207. data/lib/tasks/site_custom_field_groups.rake +36 -0
  208. data/lib/tasks/unsafe_stored_content.rake +42 -0
  209. data/lib/tasks/unsafe_stored_uploads.rake +70 -0
  210. data/lib/tasks/user_field_groups.rake +27 -0
  211. metadata +123 -4
@@ -12,19 +12,28 @@ module CamaleonCms
12
12
 
13
13
  def index
14
14
  authorize! :posts, @post_type
15
+ # Array/hash-typed query params (?q[]=x, ?s[]=published, ?taxonomy_id[]=1) have no meaning here
16
+ # and 500ed the action (Array#downcase / Array#to_sym / find(Array).decorate); treat them as absent.
17
+ %i[q s taxonomy taxonomy_id].each { |k| params[k] = nil unless params[k].nil? || params[k].is_a?(String) }
15
18
  per_page = current_site.admin_per_page
16
19
  posts_all = @post_type.posts.eager_load(:parent, :post_type)
17
20
  if params[:taxonomy].present? && params[:taxonomy_id].present?
21
+ # Security (audit 2026-08-11 M11): keep the listing scoped to @post_type. The taxonomy is
22
+ # resolved within the authorized post type -- a foreign category/tag id raises RecordNotFound
23
+ # instead of rendering that taxonomy's title/edit URL in the breadcrumb (a name/existence
24
+ # oracle across the very boundary `authorize! :posts, @post_type` draws). The listing then
25
+ # intersects with the post type's posts, so a filter can only ever narrow the authorized set.
18
26
  if params[:taxonomy] == 'category'
19
- cat_owner = current_site.full_categories.find(params[:taxonomy_id]).decorate
20
- posts_all = cat_owner.posts
27
+ cat_owner = @post_type.full_categories.find(params[:taxonomy_id]).decorate
28
+ # reorder(nil): the relation becomes an IN subquery, where its default-scope ORDER BY is dead sort work
29
+ posts_all = posts_all.where(id: cat_owner.posts.reorder(nil))
21
30
  add_breadcrumb t('camaleon_cms.admin.post_type.category'), @post_type.the_admin_url('category')
22
31
  add_breadcrumb cat_owner.the_title, cat_owner.the_edit_url
23
32
  end
24
33
 
25
34
  if params[:taxonomy] == 'post_tag'
26
- tag_owner = current_site.post_tags.find(params[:taxonomy_id]).decorate
27
- posts_all = tag_owner.posts
35
+ tag_owner = @post_type.post_tags.find(params[:taxonomy_id]).decorate
36
+ posts_all = posts_all.where(id: tag_owner.posts.reorder(nil))
28
37
  add_breadcrumb t('camaleon_cms.admin.post_type.tags'), @post_type.the_admin_url('tag')
29
38
  add_breadcrumb tag_owner.the_title, tag_owner.the_edit_url
30
39
  end
@@ -39,10 +48,10 @@ module CamaleonCms
39
48
  )
40
49
  end
41
50
 
42
- posts_all = posts_all.where(user_id: cama_current_user) if cannot?(:edit_other, @post_type)
51
+ posts_all = cama_admin_visible_posts(posts_all, [@post_type])
43
52
 
44
53
  @posts = posts_all
45
- params[:s] = 'published' unless params[:s].present?
54
+ params[:s] = 'published' if params[:s].blank?
46
55
  @lists_tab = params[:s]
47
56
  case params[:s]
48
57
  when 'published', 'pending', 'trash'
@@ -53,8 +62,13 @@ module CamaleonCms
53
62
  @posts = @posts.no_trash
54
63
  end
55
64
 
56
- @btns = { published: "#{t('camaleon_cms.admin.post_type.published')} (#{posts_all.published.size})",
57
- all: "#{t('camaleon_cms.admin.post_type.all')} (#{posts_all.no_trash.size})", pending: "#{t('camaleon_cms.admin.post_type.pending')} (#{posts_all.pending.size})", draft: "#{t('camaleon_cms.admin.post_type.draft')} (#{posts_all.drafts.size})", trash: "#{t('camaleon_cms.admin.post_type.trash')} (#{posts_all.trash.size})" }
65
+ @btns = {
66
+ published: "#{t('camaleon_cms.admin.post_type.published')} (#{posts_all.published.size})",
67
+ all: "#{t('camaleon_cms.admin.post_type.all')} (#{posts_all.no_trash.size})",
68
+ pending: "#{t('camaleon_cms.admin.post_type.pending')} (#{posts_all.pending.size})",
69
+ draft: "#{t('camaleon_cms.admin.post_type.draft')} (#{posts_all.drafts.size})",
70
+ trash: "#{t('camaleon_cms.admin.post_type.trash')} (#{posts_all.trash.size})"
71
+ }
58
72
  per_page = 9_999_999 if @post_type.manage_hierarchy?
59
73
  r = { posts: @posts, post_type: @post_type, btns: @btns, all_posts: posts_all, render: 'index',
60
74
  per_page: per_page }
@@ -88,10 +102,7 @@ module CamaleonCms
88
102
  r = { post: @post, post_type: @post_type }
89
103
  hooks_run('create_post', r)
90
104
  @post = r[:post]
91
- if @post.save
92
- @post.set_metas(params[:meta])
93
- @post.set_field_values(cama_permitted_field_options('PostType_Post'))
94
- @post.set_options(params[:options])
105
+ if save_post_with_fields(@post)
95
106
  flash[:notice] = t('camaleon_cms.admin.post.message.created', post_type: @post_type.decorate.the_title)
96
107
  r = { post: @post, post_type: @post_type }
97
108
  hooks_run('created_post', r)
@@ -117,7 +128,7 @@ module CamaleonCms
117
128
  if @post.draft_child? && @post.parent.present?
118
129
  # This is a draft (as a child of the original post)
119
130
  original_parent = @post.parent.parent
120
- post_data[:post_parent] = original_parent.present? ? original_parent.id : nil
131
+ post_data[:post_parent] = original_parent&.id
121
132
  @post = @post.parent
122
133
  delete_drafts = true
123
134
  elsif @post.draft?
@@ -128,12 +139,9 @@ module CamaleonCms
128
139
  r = { post: @post, post_type: @post_type }
129
140
  hooks_run('update_post', r)
130
141
  @post = r[:post]
131
- if @post.update(post_data)
142
+ if save_post_with_fields(@post, post_data)
132
143
  # delete drafts only on successful update operation
133
144
  @post.drafts.destroy_all if delete_drafts
134
- @post.set_metas(params[:meta])
135
- @post.set_field_values(cama_permitted_field_options('PostType_Post'))
136
- @post.set_options(params[:options])
137
145
  hooks_run('updated_post', { post: @post, post_type: @post_type })
138
146
  flash[:notice] = t('camaleon_cms.admin.post.message.updated', post_type: @post_type.decorate.the_title)
139
147
  redirect_to action: :edit, id: @post.id
@@ -147,7 +155,7 @@ module CamaleonCms
147
155
  authorize! :destroy, @post
148
156
  @post.set_option('status_default', @post.status)
149
157
  # @post.children.destroy_all unless @post.draft? TODO: why delete children?
150
- @post.update_column('status', 'trash')
158
+ @post.update_column(:status, 'trash') # rubocop:disable Rails/SkipsModelValidations
151
159
  @post.update_extra_data
152
160
  hooks_run('trashed_post', { post: @post, post_type: @post_type })
153
161
  flash[:notice] = t('camaleon_cms.admin.post.message.trash', post_type: @post_type.decorate.the_title)
@@ -157,7 +165,9 @@ module CamaleonCms
157
165
  def restore
158
166
  @post = @post_type.posts.find(params[:post_id])
159
167
  authorize! :update, @post
160
- @post.update_column('status', @post.options[:status_default] || 'pending')
168
+ # rubocop:disable Rails/SkipsModelValidations
169
+ @post.update_column(:status, @post.options[:status_default] || 'pending')
170
+ # rubocop:enable Rails/SkipsModelValidations
161
171
  @post.update_extra_data
162
172
  hooks_run('restored_post', { post: @post, post_type: @post_type })
163
173
  flash[:notice] = t('camaleon_cms.admin.post.message.restore', post_type: @post_type.decorate.the_title)
@@ -193,10 +203,29 @@ module CamaleonCms
193
203
 
194
204
  private
195
205
 
206
+ # Persist the post together with its metas, field values and options atomically (audit M10).
207
+ # Before this, the parent was saved and its metas committed before set_field_values ran, so a
208
+ # field value the scan-and-reject gate refused (CustomFieldsRelationship RecordInvalid) left a
209
+ # half-applied post -- an orphan on create, skipped options, only a redirect explaining why.
210
+ # Wrapping the whole sequence in one transaction rolls the parent save back with the refused
211
+ # value, and the RecordInvalid propagates to AdminController's rescue_from (flash + redirect
212
+ # back) with nothing persisted. Returns true on success, false on a parent validation failure.
213
+ def save_post_with_fields(post, update_attrs = nil)
214
+ ActiveRecord::Base.transaction do
215
+ saved = update_attrs ? post.update(update_attrs) : post.save
216
+ raise ActiveRecord::Rollback unless saved
217
+
218
+ post.set_metas(params[:meta])
219
+ post.set_field_values(cama_permitted_field_options('PostType_Post'))
220
+ post.set_options(params[:options])
221
+ true
222
+ end
223
+ end
224
+
196
225
  # define post type parent
197
226
  def set_post_type
198
- @post_type = current_site.post_types.find_by_id(params[:post_type_id])
199
- unless @post_type.present?
227
+ @post_type = current_site.post_types.find_by(id: params[:post_type_id])
228
+ if @post_type.blank?
200
229
  flash[:error] = t('camaleon_cms.admin.request_error_message')
201
230
  redirect_to cama_admin_path
202
231
  return
@@ -216,7 +245,11 @@ module CamaleonCms
216
245
  # return common params data for posts
217
246
  # is_create: indicate if this info is for create a new post
218
247
  def get_post_data(is_create = false)
219
- post_data = params.require(:post).permit(:title, :slug, :content, :excerpt, :status, :comment_status, :post_parent, :visibility, :visibility_value, :post_order, :published_at).to_h
248
+ post_data = params
249
+ .require(:post).permit(
250
+ :title, :slug, :content, :excerpt, :status, :comment_status, :post_parent, :visibility,
251
+ :visibility_value, :post_order, :published_at
252
+ ).to_h
220
253
  post_data[:user_id] = cama_current_user.id if is_create
221
254
  post_data[:status] = 'pending' if post_data[:status] == 'published' && cannot?(:publish_post, @post_type)
222
255
  post_data[:data_tags] = params[:tags].to_s
@@ -7,9 +7,31 @@ module CamaleonCms
7
7
  before_action :verificate_register_permission, only: [:register]
8
8
  layout 'camaleon_cms/login'
9
9
 
10
- # you can pass return_to as a param (mysite.com/admin/login?return_to=my-url) and this will be used after user logged in
10
+ # Security (audit M6): logout acts only over POST, so a logout submitted with a stale CSRF token
11
+ # (a second tab that already reset the session, or a cached theme page) would otherwise render a
12
+ # raw 422 instead of ending the session. For logout alone, fall back to the GET confirmation so
13
+ # the user gets a fresh token and one extra click; every other action keeps the strict failure.
14
+ rescue_from ActionController::InvalidAuthenticityToken do |error|
15
+ raise error unless action_name == 'logout'
16
+
17
+ redirect_to cama_admin_logout_path
18
+ end
19
+
20
+ # A precomputed bcrypt digest spent only to equalize login timing when the username does not exist,
21
+ # so a missing username is not distinguishable from a wrong password by response time (audit finding
22
+ # M14: username enumeration by login timing). Computed once at load, at this environment's cost.
23
+ TIMING_EQUALIZER_DIGEST = BCrypt::Password.create('cama-login-timing-equalizer').to_s.freeze
24
+
25
+ # Minimum interval between password-reset emails to the same account, so the forgot-password
26
+ # endpoint cannot be used to flood a known address (audit finding M13). Within the window a
27
+ # reset request is accepted (and answered identically) but sends no new email; the previously
28
+ # issued token stays valid for its 2h lifetime (see #forgot).
29
+ PASSWORD_RESET_EMAIL_COOLDOWN = 5.minutes
30
+
31
+ # you can pass return_to as a param (mysite.com/admin/login?return_to=my-url) and
32
+ # this will be used after user logged in
11
33
  def login
12
- return redirect_to(params[:return_to].present? ? params[:return_to] : cama_admin_dashboard_path) if signin?
34
+ return cama_safe_redirect(params[:return_to], cama_admin_dashboard_path) if signin?
13
35
 
14
36
  cookies[:return_to] = params[:return_to] if params[:return_to].present?
15
37
  @user ||= current_site.users.new
@@ -18,21 +40,28 @@ module CamaleonCms
18
40
  end
19
41
 
20
42
  def login_post
43
+ return if cama_halt_when_login_locked_out('login')
44
+
21
45
  data_user = user_permit_data
22
- @user = current_site.users.find_by_username(data_user[:username])
46
+ # Custom class finder (not the dynamic finder): usernames are stored downcased,
47
+ # so the lookup must lower-case both sides regardless of DB collation.
48
+ @user = current_site.users.find_by_username(data_user[:username]) # rubocop:disable Rails/DynamicFindBy
23
49
  captcha_validate = captcha_verify_if_under_attack('login')
24
50
  r = { user: @user, params: params, password: data_user[:password], captcha_validate: captcha_validate,
25
51
  stop_process: false }
26
52
  hooks_run('user_before_login', r)
27
53
  return if r[:stop_process] # permit to redirect for data completion
28
54
 
29
- if captcha_validate && @user&.authenticate(data_user[:password])
55
+ if captcha_validate && cama_password_matches?(@user, data_user[:password])
30
56
  # Email validation if is necessary
31
57
  if @user.is_valid_email? || !current_site.need_validate_email?
32
58
  cama_captcha_reset_attack('login')
33
59
  r = { user: @user, redirect_to: params[:format] == 'json' ? false : nil }
34
60
  hooks_run('after_login', r)
35
- login_user(@user, params[:remember_me].present?, r[:redirect_to])
61
+ # An after_login hook may set r[:allow_external_redirect] to vouch for an off-site r[:redirect_to]
62
+ # (SSO/payment); without it the destination is same-host/allowlist-only. See login_user.
63
+ login_user(@user, params[:remember_me].present?, r[:redirect_to],
64
+ allow_external: r[:allow_external_redirect].present?)
36
65
  render(json: flash.discard.to_hash) if params[:format] == 'json'
37
66
  return
38
67
  else
@@ -54,75 +83,161 @@ module CamaleonCms
54
83
  end
55
84
 
56
85
  def logout
57
- if session[:parent_auth_token].present? && cama_sign_in?
58
- session_back_to_parent(cama_admin_dashboard_path)
59
- else
86
+ # A request that is no longer authenticated has nothing to confirm and nothing a forged GET
87
+ # could end -- but it may still carry session leftovers (a stale impersonation stash, H6),
88
+ # so it goes through cama_logout_user's cleanup on any verb, exactly as before.
89
+ return cama_logout_user unless cama_sign_in?
90
+
91
+ # While impersonating, the ordinary Logout link must not silently hand the admin account back
92
+ # to whoever holds the session — returning to the parent now requires the admin's password
93
+ # (see #back_to_parent). `?full=1` forces a real logout of the impersonated session instead.
94
+ if session[:parent_auth_token].present? && params[:full].blank?
95
+ redirect_to cama_admin_back_to_parent_path
96
+ elsif request.post?
60
97
  cama_logout_user
98
+ else
99
+ # Security (audit M6): logging out changes state, so only the POST above performs it —
100
+ # keyed on request.post?, not !request.get?, because Rails exempts HEAD from CSRF exactly
101
+ # like GET. The GET renders a confirmation instead of 404ing: frontend themes across the
102
+ # ecosystem link this path, and their visitors get one extra click, not a broken link.
103
+ prepare_logout_confirmation
104
+ # Force the HTML template/layout: the confirmation is HTML-only, so a non-HTML GET (an ajax
105
+ # `/admin/logout.json`) renders the same confirmation instead of raising MissingTemplate (500),
106
+ # still leaving the session untouched.
107
+ render :logout_confirm, formats: [:html], content_type: 'text/html'
61
108
  end
62
109
  end
63
110
 
111
+ # Re-authenticate the impersonating admin before restoring their session (H6 residual). Reachable
112
+ # only while an impersonation is active; a GET renders the confirmation form, a POST checks the
113
+ # admin's password and only then returns to the parent session.
114
+ def back_to_parent
115
+ return redirect_to(cama_admin_login_path) unless session[:parent_auth_token].present? && cama_sign_in?
116
+
117
+ @parent_user = cama_impersonation_parent_user
118
+ # A stash that no longer resolves to a user — or resolves to an account with no password
119
+ # digest, which #authenticate would raise on and which cannot prove the holder is the admin —
120
+ # cannot be returned to; fail closed and end the session.
121
+ return cama_logout_user if @parent_user.blank? || @parent_user.password_digest.blank?
122
+
123
+ @impersonated_user = cama_current_user
124
+ if request.post?
125
+ return if cama_halt_when_login_locked_out('back_to_parent')
126
+
127
+ # Failed guesses feed the login form's attack counter (same 'login' key), so this endpoint is
128
+ # not an unthrottled oracle for the admin password: past the threshold a captcha is also
129
+ # required, and because the parent stash lives in this same session, resetting the counter
130
+ # (new session / full logout) destroys the stash being attacked.
131
+ captcha_validate = captcha_verify_if_under_attack('login')
132
+ if captcha_validate && @parent_user.authenticate(params[:password].to_s)
133
+ cama_captcha_reset_attack('login')
134
+ return session_back_to_parent(cama_admin_dashboard_path)
135
+ end
136
+
137
+ cama_captcha_increment_attack('login')
138
+ flash.now[:error] = if captcha_validate
139
+ t('camaleon_cms.admin.login.message.reauth_failed',
140
+ default: 'Incorrect password. Please try again.')
141
+ else
142
+ t('camaleon_cms.admin.login.message.invalid_caption')
143
+ end
144
+ end
145
+ render 'back_to_parent'
146
+ end
147
+
64
148
  def forgot
65
149
  @user = current_site.users.new
66
150
  # get form reset password
67
- if params[:h]
151
+ if params[:h].present?
152
+ # Look up by the DB column (a `where` clause is never shadowed by the Rails 7.1+
153
+ # has_secure_password method), scoped to the current site so a token cannot be redeemed
154
+ # against a site that does not own the account.
68
155
  @user = current_site.users.where(password_reset_token: params[:h]).first
69
156
  if @user.nil?
70
157
  flash[:error] = t('camaleon_cms.admin.login.message.forgot_url_incorrect')
71
- redirect_to cama_admin_forgot_path
72
- return
73
- elsif @user.password_reset_sent_at < 2.hours.ago
158
+ return redirect_to cama_admin_forgot_path
159
+ end
160
+ # nil timestamp (or one past the window) is expired, not a NoMethodError.
161
+ if @user.password_reset_sent_at.blank? || @user.password_reset_sent_at < 2.hours.ago
74
162
  flash[:error] = t('camaleon_cms.admin.login.message.forgot_expired')
75
- redirect_to cama_admin_login_path
76
- else
77
- # saved new password
78
- if params[:user].present?
79
- if @user.update(params[:user].permit(:password, :password_confirmation))
80
- flash[:notice] = t('camaleon_cms.admin.login.message.reset_password_succes')
81
- redirect_to cama_admin_login_path
82
- return
83
- else
84
- flash[:error] = t('camaleon_cms.admin.login.message.reset_password_error')
85
- end
163
+ return redirect_to cama_admin_login_path
164
+ end
165
+
166
+ if params[:user].present?
167
+ # Blank-check the *permitted* password: permit drops a non-scalar value (e.g.
168
+ # `user[password][]=x`), and has_secure_password only validates presence on create —
169
+ # either way `update` would be a silent no-op reported as success, consuming the token.
170
+ # A scalar `user` param (`?user=foo`) has no .permit: treat it as an empty submission.
171
+ reset_params = params[:user].try(:permit, :password, :password_confirmation) || {}
172
+ if reset_params[:password].blank?
173
+ flash[:error] = t('camaleon_cms.admin.login.message.reset_password_error')
174
+ elsif @user.update(reset_params)
175
+ # Single-use: clear the token so the same link cannot be replayed.
176
+ @user.update_columns(password_reset_token: nil, password_reset_sent_at: nil) # rubocop:disable Rails/SkipsModelValidations
177
+ flash[:notice] = t('camaleon_cms.admin.login.message.reset_password_succes')
178
+ return redirect_to cama_admin_login_path
179
+ else
180
+ flash[:error] = t('camaleon_cms.admin.login.message.reset_password_error')
86
181
  end
87
- @form_reset = true
88
- render 'forgot'
89
- return
90
182
  end
183
+ @form_reset = true
184
+ return render 'forgot'
91
185
  end
92
186
 
93
187
  # TODO: Move this out of the controller
94
188
  # send email reset password
95
- return unless params[:user].present?
189
+ return if params[:user].blank?
96
190
 
97
191
  data_user = user_permit_data
98
- @user = current_site.users.find_by_email(data_user[:email])
99
- if @user.present?
100
- send_password_reset_email(@user)
101
- flash[:notice] = t('camaleon_cms.admin.login.message.send_mail_succes')
102
- redirect_to cama_admin_login_path
103
- nil
104
- else
105
- flash[:error] = t('camaleon_cms.admin.login.message.send_mail_error')
106
- @user = current_site.users.new(data_user)
107
- end
192
+ # Custom class finder: emails are stored downcased, so the lookup must
193
+ # lower-case both sides regardless of DB collation.
194
+ @user = current_site.users.find_by_email(data_user[:email]) # rubocop:disable Rails/DynamicFindBy
195
+ # Security (audit 2026-08-11 M13): do not reveal whether an account exists, and do not let the
196
+ # endpoint mail-bomb a known address. Send at most one reset email per cooldown window, and
197
+ # always answer with the same neutral message whether or not the email matched an account.
198
+ send_password_reset_email(@user) if @user.present? && cama_password_reset_email_allowed?(@user)
199
+ # Only en.yml carries this key while the process locale follows the current admin/site
200
+ # language (audit M15) -- fall back to English rather than emit "translation missing".
201
+ flash[:notice] = t('camaleon_cms.admin.login.message.password_reset_requested',
202
+ default: t('camaleon_cms.admin.login.message.password_reset_requested', locale: :en))
203
+ redirect_to cama_admin_login_path
108
204
  end
109
205
 
110
206
  def register
111
207
  @user ||= current_site.users.new
112
- if params[:user].present?
208
+ # A scalar `user` param has no .permit — only a form-shaped submission enters this branch.
209
+ if params[:user].respond_to?(:permit)
113
210
  params[:user][:role] = PluginRoutes.system_info['default_user_role']
114
211
  params[:user][:is_valid_email] = false if current_site.need_validate_email?
115
212
  user_data = user_permit_data
116
213
  result = cama_register_user(user_data, nil)
117
214
  if result[:result] == false && result[:type] == :captcha_error
118
- @user.errors.add(:captcha, t('camaleon_cms.admin.users.message.error_captcha'))
215
+ @user.errors.add(:captcha, result[:message])
119
216
  render 'register'
217
+ elsif result[:result] == false && result[:type] == :stopped
218
+ # A vetoing user_before_register handler may take over the response itself (render/redirect,
219
+ # the user_before_login convention) or add its own errors to r[:user]; rendering again would
220
+ # raise DoubleRenderError, so re-render only when it did not — and show a generic reason so the
221
+ # visitor is not left with a silently re-rendered form.
222
+ unless performed?
223
+ if @user.errors.empty?
224
+ @user.errors.add(:base, t('camaleon_cms.admin.users.message.registration_stopped',
225
+ default: 'Registration could not be completed.'))
226
+ end
227
+ render 'register'
228
+ end
120
229
  elsif result[:result]
230
+ @user = result[:user] if result[:user].present?
121
231
  flash[:notice] = result[:message]
122
232
  send_user_confirm_email(@user) if current_site.need_validate_email?
123
233
  r = { user: @user, redirect_url: result[:redirect_url] }
124
234
  hooks_run('user_registered', r)
125
- redirect_to r[:redirect_url]
235
+ # Host-check the post-registration destination for the same reason login_user host-checks its
236
+ # explicit redirect_url: the user_registered hook is caller-controlled (downstream plugins), so
237
+ # an off-site value falls back to the safe default unless the hook vouches for it via
238
+ # r[:allow_external_redirect] (or its host is allowlisted).
239
+ cama_safe_redirect(r[:redirect_url], cama_admin_login_path,
240
+ allow_external: r[:allow_external_redirect].present?)
126
241
  else
127
242
  render 'register'
128
243
  end
@@ -143,6 +258,9 @@ module CamaleonCms
143
258
  flash[:notice] = t('camaleon_cms.admin.login.message.confirm_email_success')
144
259
  @user.is_valid_email = true
145
260
  @user.save!
261
+ # Security (audit M16): single-use, like the password-reset token -- clear it so the same
262
+ # confirmation link cannot be replayed.
263
+ @user.update_columns(confirm_email_token: nil, confirm_email_sent_at: nil) # rubocop:disable Rails/SkipsModelValidations
146
264
  end
147
265
  end
148
266
  redirect_to cama_admin_login_path
@@ -150,13 +268,83 @@ module CamaleonCms
150
268
 
151
269
  private
152
270
 
271
+ # Data for the GET logout confirmation page (see #logout). Carries the caller's own logout
272
+ # parameters into the POST button, but only scalar values -- a hash-shaped ?full[x]= or
273
+ # ?return_to[x]= would otherwise crash URL generation with UnfilteredParameters (500). The
274
+ # Cancel destination is the vetted return_to when present (frontend themes link logout with a
275
+ # return-to-site URL) so cancelling goes back to the site, not into the admin panel.
276
+ def prepare_logout_confirmation
277
+ @logout_full = params[:full].presence if params[:full].is_a?(String)
278
+ @logout_return_to = params[:return_to] if params[:return_to].is_a?(String)
279
+ @logout_locale = params[:locale] if params[:locale].is_a?(String)
280
+ @logout_cancel_url = safe_redirect_url(@logout_return_to).presence || cama_admin_dashboard_path
281
+ end
282
+
283
+ # Hard brute-force lockout (audit finding H1): once the per-IP failed-login counter reaches the
284
+ # limit, refuse every attempt from that IP (renders the given template with 429) for the cooldown
285
+ # window, regardless of credentials or captcha. Re-counting the blocked attempt keeps the window
286
+ # fresh so a persistent attacker stays locked. Returns true when it halted the action.
287
+ def cama_halt_when_login_locked_out(template)
288
+ return false unless cama_captcha_attack_ip_count('login') >= cama_login_lockout_limit
289
+
290
+ cama_captcha_increment_attack('login')
291
+ msg = t('camaleon_cms.admin.login.message.too_many_attempts',
292
+ default: 'Too many failed attempts. Please try again later.')
293
+ if params[:format] == 'json'
294
+ render json: { error: msg }, status: :too_many_requests
295
+ else
296
+ flash.now[:error] = msg
297
+ @user ||= current_site.users.new
298
+ render template, status: :too_many_requests
299
+ end
300
+ true
301
+ end
302
+
303
+ # Failures past this per-IP threshold trigger the hard lockout; defaults to 4x the captcha
304
+ # (soft) threshold and is configurable per site.
305
+ def cama_login_lockout_limit
306
+ soft = current_site.get_option('max_try_attack', 5).to_i
307
+ current_site.get_option('login_lockout_attempts', soft * 4).to_i
308
+ end
309
+
310
+ # Verify the password while spending one bcrypt comparison whether or not the username exists, so
311
+ # an unknown username takes about as long as a wrong password (audit M14). The dummy comparison on
312
+ # the missing-user branch does the same work the real path does (BCrypt::Password#is_password?).
313
+ def cama_password_matches?(user, password)
314
+ return user.authenticate(password) if user
315
+
316
+ BCrypt::Password.new(TIMING_EQUALIZER_DIGEST).is_password?(password.to_s)
317
+ false
318
+ end
319
+
320
+ # Whether a fresh reset email may be sent to this account, throttled per account so the endpoint
321
+ # cannot flood a known inbox (M13). Reads the column directly (not shadowed, unlike the token).
322
+ def cama_password_reset_email_allowed?(user)
323
+ sent_at = user.password_reset_sent_at
324
+ sent_at.blank? || sent_at < PASSWORD_RESET_EMAIL_COOLDOWN.ago
325
+ end
326
+
153
327
  def before_hook_session
154
- session[:cama_current_language] = params[:cama_set_language].to_sym if params[:cama_set_language].present?
155
- session[:cama_current_language] = nil if current_site.get_languages.exclude?(session[:cama_current_language])
156
- I18n.locale = params[:locale] || session[:cama_current_language] || current_site.get_languages.first
328
+ # NOTE: session[:cama_current_language] is for FRONTEND only (user's site language choice)
329
+ # Admin should use admin language setting, not frontend language
330
+ # This prevents language mixing when switching between frontend and admin contexts
331
+ set_login_locale
157
332
  hooks_run('session_before_load')
158
333
  end
159
334
 
335
+ # These pages render before authentication, so the site's languages — not a user
336
+ # preference — pick the locale; ?locale= applies only when scalar (?locale[]=
337
+ # would crash to_sym) and offered by the site
338
+ def set_login_locale
339
+ site_languages = current_site.get_languages
340
+ requested = (params[:locale].presence&.to_sym if params[:locale].is_a?(String))
341
+ I18n.locale = if site_languages.include?(requested)
342
+ requested
343
+ else
344
+ site_languages.first || I18n.default_locale
345
+ end
346
+ end
347
+
160
348
  def after_hook_session
161
349
  hooks_run('session_after_load')
162
350
  end
@@ -171,7 +359,12 @@ module CamaleonCms
171
359
  end
172
360
 
173
361
  def user_permit_data
174
- params.require(:user).permit(:first_name, :last_name, :email, :username, :password, :password_confirmation, :is_valid_email)
362
+ user_params = params.require(:user)
363
+ # A scalar `user` param (`?user=foo`) has no .permit: treat it as an empty submission.
364
+ return {} unless user_params.respond_to?(:permit)
365
+
366
+ user_params.permit(:first_name, :last_name, :email, :username, :password, :password_confirmation,
367
+ :is_valid_email)
175
368
  end
176
369
  end
177
370
  end