camaleon_cms 2.9.2 → 2.9.4

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 (216) 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 +14 -10
  8. data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
  9. data/app/apps/plugins/front_cache/config/locales/translation.yml +0 -7
  10. data/app/apps/plugins/front_cache/front_cache_helper.rb +109 -37
  11. data/app/apps/plugins/front_cache/views/admin/settings.html.erb +0 -5
  12. data/app/apps/plugins/visibility_post/config/config.json +1 -0
  13. data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
  14. data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
  15. data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
  16. data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
  17. data/app/apps/themes/default/assets/css/style.css.scss +24 -0
  18. data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
  19. data/app/apps/themes/default/default_helper.rb +2 -1
  20. data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
  21. data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
  22. data/app/apps/themes/new/custom_helper.rb +5 -9
  23. data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
  24. data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
  25. data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
  26. data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
  27. data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
  28. data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +8 -6
  29. data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
  30. data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +7 -2
  31. data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +2 -1
  32. data/app/controllers/camaleon_cms/admin/categories_controller.rb +19 -4
  33. data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
  34. data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
  35. data/app/controllers/camaleon_cms/admin/media_controller.rb +66 -14
  36. data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
  37. data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +10 -4
  38. data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
  39. data/app/controllers/camaleon_cms/admin/posts_controller.rb +55 -22
  40. data/app/controllers/camaleon_cms/admin/sessions_controller.rb +237 -44
  41. data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +80 -13
  42. data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +5 -2
  43. data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +26 -3
  44. data/app/controllers/camaleon_cms/admin/settings_controller.rb +5 -3
  45. data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +7 -5
  46. data/app/controllers/camaleon_cms/admin/users_controller.rb +78 -11
  47. data/app/controllers/camaleon_cms/admin_controller.rb +136 -26
  48. data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
  49. data/app/controllers/camaleon_cms/frontend_controller.rb +146 -74
  50. data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +20 -5
  51. data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
  52. data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
  53. data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
  54. data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
  55. data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
  56. data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
  57. data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
  58. data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
  59. data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
  60. data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
  61. data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
  62. data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
  63. data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
  64. data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
  65. data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
  66. data/app/decorators/camaleon_cms/post_decorator.rb +68 -50
  67. data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
  68. data/app/decorators/camaleon_cms/site_decorator.rb +33 -25
  69. data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
  70. data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
  71. data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
  72. data/app/helpers/camaleon_cms/admin/application_helper.rb +5 -8
  73. data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
  74. data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
  75. data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
  76. data/app/helpers/camaleon_cms/admin/post_type_helper.rb +34 -17
  77. data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
  78. data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
  79. data/app/helpers/camaleon_cms/comment_helper.rb +19 -7
  80. data/app/helpers/camaleon_cms/content_helper.rb +13 -6
  81. data/app/helpers/camaleon_cms/email_helper.rb +7 -3
  82. data/app/helpers/camaleon_cms/frontend/application_helper.rb +6 -1
  83. data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
  84. data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +143 -66
  85. data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
  86. data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
  87. data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
  88. data/app/helpers/camaleon_cms/html_helper.rb +58 -37
  89. data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
  90. data/app/helpers/camaleon_cms/session_helper.rb +188 -44
  91. data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
  92. data/app/helpers/camaleon_cms/site_helper.rb +34 -13
  93. data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
  94. data/app/helpers/camaleon_cms/uploader_helper.rb +19 -473
  95. data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
  96. data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
  97. data/app/models/camaleon_cms/ability.rb +15 -7
  98. data/app/models/camaleon_cms/category.rb +6 -6
  99. data/app/models/camaleon_cms/custom_field.rb +15 -6
  100. data/app/models/camaleon_cms/custom_field_group.rb +39 -22
  101. data/app/models/camaleon_cms/custom_fields_relationship.rb +140 -2
  102. data/app/models/camaleon_cms/media.rb +8 -2
  103. data/app/models/camaleon_cms/meta.rb +2 -2
  104. data/app/models/camaleon_cms/nav_menu.rb +5 -3
  105. data/app/models/camaleon_cms/nav_menu_item.rb +16 -8
  106. data/app/models/camaleon_cms/plugin.rb +3 -4
  107. data/app/models/camaleon_cms/post.rb +137 -27
  108. data/app/models/camaleon_cms/post_comment.rb +10 -6
  109. data/app/models/camaleon_cms/post_default.rb +42 -12
  110. data/app/models/camaleon_cms/post_relationship.rb +6 -4
  111. data/app/models/camaleon_cms/post_tag.rb +4 -5
  112. data/app/models/camaleon_cms/post_type.rb +21 -13
  113. data/app/models/camaleon_cms/site.rb +39 -29
  114. data/app/models/camaleon_cms/term_relationship.rb +10 -7
  115. data/app/models/camaleon_cms/term_taxonomy.rb +58 -4
  116. data/app/models/camaleon_cms/theme.rb +2 -4
  117. data/app/models/camaleon_cms/user.rb +17 -5
  118. data/app/models/camaleon_cms/user_role.rb +81 -6
  119. data/app/models/camaleon_cms/widget/assigned.rb +11 -6
  120. data/app/models/camaleon_cms/widget/main.rb +12 -8
  121. data/app/models/camaleon_cms/widget/sidebar.rb +3 -8
  122. data/app/models/camaleon_record.rb +89 -5
  123. data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +36 -42
  124. data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
  125. data/app/models/concerns/camaleon_cms/content_shortcode_gate.rb +73 -0
  126. data/app/models/concerns/camaleon_cms/custom_fields_read.rb +111 -54
  127. data/app/models/concerns/camaleon_cms/metas.rb +31 -6
  128. data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
  129. data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
  130. data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
  131. data/app/models/current_request.rb +8 -1
  132. data/app/uploaders/camaleon_cms_aws_uploader.rb +31 -10
  133. data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
  134. data/app/uploaders/camaleon_cms_uploader.rb +23 -10
  135. data/app/validators/camaleon_cms/post_uniq_validator.rb +5 -4
  136. data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
  137. data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
  138. data/app/views/camaleon_cms/admin/categories/index.html.erb +2 -2
  139. data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
  140. data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
  141. data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
  142. data/app/views/camaleon_cms/admin/post_tags/index.html.erb +2 -2
  143. data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +1 -1
  144. data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
  145. data/app/views/camaleon_cms/admin/posts/index.html.erb +5 -5
  146. data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
  147. data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
  148. data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
  149. data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
  150. data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +19 -4
  151. data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
  152. data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +4 -3
  153. data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
  154. data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
  155. data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
  156. data/app/views/camaleon_cms/admin/user_roles/form.html.erb +24 -3
  157. data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
  158. data/app/views/camaleon_cms/admin/users/index.html.erb +1 -1
  159. data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
  160. data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
  161. data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
  162. data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
  163. data/config/initializers/action_view.rb +42 -5
  164. data/config/initializers/active_record_extension.rb +13 -27
  165. data/config/initializers/assets.rb +52 -23
  166. data/config/initializers/custom_initializers.rb +1 -1
  167. data/config/locales/camaleon_cms/admin/ar.yml +1 -0
  168. data/config/locales/camaleon_cms/admin/de.yml +1 -0
  169. data/config/locales/camaleon_cms/admin/en.yml +36 -0
  170. data/config/locales/camaleon_cms/admin/es.yml +1 -0
  171. data/config/locales/camaleon_cms/admin/fr.yml +1 -0
  172. data/config/locales/camaleon_cms/admin/it.yml +1 -0
  173. data/config/locales/camaleon_cms/admin/nl.yml +1 -0
  174. data/config/locales/camaleon_cms/admin/pt-BR.yml +1 -0
  175. data/config/locales/camaleon_cms/admin/pt.yml +1 -0
  176. data/config/locales/camaleon_cms/admin/ru.yml +1 -0
  177. data/config/locales/camaleon_cms/admin/uk.yml +1 -0
  178. data/config/locales/camaleon_cms/admin/zh-CH.yml +1 -0
  179. data/config/routes/admin.rb +33 -15
  180. data/config/routes/frontend.rb +21 -15
  181. data/lib/camaleon_cms/assets_precompile.rb +86 -0
  182. data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
  183. data/lib/camaleon_cms/content_security.rb +158 -0
  184. data/lib/camaleon_cms/engine.rb +45 -21
  185. data/lib/camaleon_cms/media_security_headers.rb +27 -0
  186. data/lib/camaleon_cms/setup_token.rb +69 -0
  187. data/lib/camaleon_cms/shortcode_registry.rb +102 -0
  188. data/lib/camaleon_cms/svg_content_checker.rb +123 -0
  189. data/lib/camaleon_cms/task_reporter.rb +14 -0
  190. data/lib/camaleon_cms/unsafe_markup.rb +207 -0
  191. data/lib/camaleon_cms/uploader_content_security.rb +248 -0
  192. data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
  193. data/lib/camaleon_cms/uploader_path_security.rb +146 -0
  194. data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
  195. data/lib/camaleon_cms/uploader_support.rb +86 -0
  196. data/lib/camaleon_cms/version.rb +1 -1
  197. data/lib/camaleon_cms.rb +23 -0
  198. data/lib/ext/hash.rb +28 -2
  199. data/lib/ext/string.rb +10 -5
  200. data/lib/ext/translator.rb +10 -2
  201. data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
  202. data/lib/generators/camaleon_cms/install_generator.rb +7 -4
  203. data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
  204. data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
  205. data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
  206. data/lib/plugin_routes.rb +412 -368
  207. data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
  208. data/lib/tasks/cross_site_field_groups.rake +74 -0
  209. data/lib/tasks/custom_fields_roles.rake +15 -13
  210. data/lib/tasks/orphaned_comments.rake +42 -0
  211. data/lib/tasks/private_upload_acls.rake +31 -0
  212. data/lib/tasks/site_custom_field_groups.rake +36 -0
  213. data/lib/tasks/unsafe_stored_content.rake +42 -0
  214. data/lib/tasks/unsafe_stored_uploads.rake +70 -0
  215. data/lib/tasks/user_field_groups.rake +27 -0
  216. metadata +125 -4
@@ -4,31 +4,15 @@ module CamaleonCms
4
4
  # verify if current user is logged in, if not, then return nil
5
5
  # return html link
6
6
  def cama_edit_link(url, title = nil, attrs = {})
7
- return '' unless cama_current_user.present?
7
+ return '' if cama_current_user.blank?
8
8
  return '' unless cama_current_user.admin?
9
9
 
10
10
  attrs = { target: '_blank', style: 'font-size:11px !important;cursor:pointer;' }.merge(attrs)
11
- ActionController::Base.helpers.link_to("→ #{title || ct('edit', default: 'Edit')}".html_safe, url, attrs)
12
- end
13
-
14
- # execute controller action and return response
15
- # NON USED
16
- def cama_requestAction(controller, action, params = {})
17
- controller.class_eval do
18
- def params=(params)
19
- @params = params
20
- end
21
-
22
- def params
23
- @params
24
- end
25
- end
26
- c = controller.new
27
- c.request = @_request
28
- c.response = @_response
29
- c.params = params
30
- c.send(action)
31
- c.response.body
11
+ link_to(
12
+ safe_join(['→ ', title || ct('edit', default: 'Edit')]),
13
+ url,
14
+ attrs
15
+ )
32
16
  end
33
17
 
34
18
  # theme common translation text
@@ -44,26 +28,37 @@ module CamaleonCms
44
28
  I18n.translate("camaleon_cms.common.#{key}", **args)
45
29
  end
46
30
 
47
- # check if current request was for admin panel
31
+ # Public compatibility API for plugins such as camaleon-ecommerce, which use
32
+ # this predicate to switch from visitor pricing/locale behavior to admin mode.
33
+ # This still matters while previewing frontend content from admin, for example
34
+ # a theme preview that renders ecommerce helpers before the request reaches the
35
+ # public frontend controller stack.
48
36
  def cama_is_admin_request?
49
- @cama_i18n_frontend.present?
37
+ respond_to?(:cama_get_i18n_frontend) && cama_get_i18n_frontend.present?
50
38
  end
51
39
 
52
40
  # generate loop categories html sitemap links
53
41
  # this is a helper for sitemap generator to print categories, sub categories and post contents in html list format
54
- def cama_sitemap_cats_generator(cats)
42
+ def cama_sitemap_cats_generator(cats, skip_config = {})
43
+ skip_config = {} unless skip_config.is_a?(Hash)
44
+ # merge! alone is not enough: an on_render_sitemap hook that sets one of these keys to nil
45
+ # overwrites the default with nil and brings back the very nil.include? crash the defaults
46
+ # exist to prevent. compact drops those before they win.
47
+ skip_config = { skip_cat_ids: [], skip_post_ids: [] }.merge!(skip_config.compact)
55
48
  res = []
56
49
  cats.decorate.each do |cat|
57
- next if @r[:skip_cat_ids].include?(cat.id)
50
+ next if skip_config[:skip_cat_ids].include?(cat.id)
58
51
 
59
52
  res_posts = []
60
53
  cat.the_posts.decorate.each do |post|
61
- next if @r[:skip_post_ids].include?(post.id)
54
+ next if skip_config[:skip_post_ids].include?(post.id)
62
55
 
63
- res_posts << "<li><a href='#{post.the_url}'>#{post.the_title}</a></li>"
56
+ post_href = ERB::Util.html_escape(post.the_url)
57
+ res_posts << "<li><a href='#{post_href}'>#{post.the_title}</a></li>"
64
58
  end
65
- res << "<li><h4><a href='#{cat.the_url}'>#{cat.the_title}</a></h4><ul>#{res_posts.join('')}</ul></li>"
66
- res << cama_sitemap_cats_generator(cat.the_categories)
59
+ cat_href = ERB::Util.html_escape(cat.the_url)
60
+ res << "<li><h4><a href='#{cat_href}'>#{cat.the_title}</a></h4><ul>#{res_posts.join('')}</ul></li>"
61
+ res << cama_sitemap_cats_generator(cat.the_categories, skip_config)
67
62
  end
68
63
  res.join('')
69
64
  end
@@ -79,22 +74,19 @@ module CamaleonCms
79
74
  cache
80
75
  end
81
76
 
82
- # =Deprecated
83
- def cama_draw_timer
84
- @_cama_timer ||= Time.current
85
- puts "***************************************** timer: #{((Time.current - @_cama_timer) * 24 * 60 * 60).to_i} (#{caller.first})"
86
- @_cama_timer = Time.current
87
- end
88
-
89
77
  # return normal translation with default value with translation of english
90
78
  def cama_t(key, args = {})
91
- args[:default] = I18n.t(key, **args.dup.merge(locale: :en)) unless args[:default].present?
79
+ args[:default] = I18n.t(key, **args.dup.merge(locale: :en)) if args[:default].blank?
92
80
  I18n.t(key, **args)
93
81
  end
94
82
 
95
83
  # function that converts string into plural format
84
+ # `SafeBuffer#pluralize` returns a plain String, so callers composing the result with other
85
+ # markup lose the safe flag before they can use it. The safeness of the input is propagated --
86
+ # never added, so an unsafe input stays unsafe and no caller becomes less safe than before.
96
87
  def cama_pluralize_text(text)
97
- text.try(:pluralize)
88
+ res = text.try(:pluralize)
89
+ text.try(:html_safe?) ? res.try(:html_safe) : res
98
90
  end
99
91
  end
100
92
  end
@@ -1,78 +1,112 @@
1
1
  module CamaleonCms
2
2
  module CaptchaHelper
3
- # build a captcha image
4
- # len: Number or characters to include in captcha (default 5)
5
- def cama_captcha_build(len = 5)
6
- img = MiniMagick::Image.open(File.join($camaleon_engine_dir.present? ? $camaleon_engine_dir : Rails.root.to_s,
7
- 'lib', 'captcha', "captcha_#{rand(12)}.jpg").to_s)
8
- text = cama_rand_str(len)
9
- session[:cama_captcha] = [] unless session[:cama_captcha].present?
10
- session[:cama_captcha] << text
11
- img.combine_options do |c|
12
- c.gravity 'Center'
13
- c.fill('#FFFFFF')
14
- c.draw "text 0,5 #{text}"
15
- c.font File.join($camaleon_engine_dir.present? ? $camaleon_engine_dir : Rails.root.to_s, 'lib', 'captcha',
16
- 'bumpyroad.ttf')
17
- c.pointsize '30'
18
- end
19
- img
20
- end
3
+ # Challenge/image generation (including the length clamp and its constants) lives in
4
+ # CamaleonCms::CaptchaImageGeneration, shared with the controller-stack
5
+ # CamaleonCms::RuntimeCaptchaImageConcern; verification and the under-attack counters
6
+ # below exist only here.
7
+ include CamaleonCms::CaptchaImageGeneration
21
8
 
22
9
  # build a captcha tag (image with captcha)
23
10
  # img_args: attributes for image_tag
24
11
  # input_args: attributes for input field
25
12
  def cama_captcha_tag(len = 5, img_args = { alt: '' }, input_args = {}, bootstrap_group_mode = false)
26
- unless input_args[:placeholder].present?
13
+ # Symbolize so string-keyed args work: the :placeholder / :style reads below use Symbol keys,
14
+ # so a caller's String key would be missed — the default placeholder would then be added under
15
+ # the Symbol key and render as a duplicate attribute.
16
+ img_args = img_args.to_h.symbolize_keys
17
+ input_args = input_args.to_h.symbolize_keys
18
+ if input_args[:placeholder].blank?
27
19
  input_args[:placeholder] =
28
- I18n.t('camaleon_cms.captcha_placeholder',
29
- default: 'Please enter the text of the image')
20
+ I18n.t('camaleon_cms.captcha_placeholder', default: 'Please enter the text of the image')
30
21
  end
31
- img_args['onclick'] = "this.src = \"#{cama_captcha_url(len: len)}\"+\"&t=\"+(new Date().getTime());"
32
- img = "<img style='cursor: pointer;' src='#{cama_captcha_url(len: len,
33
- t: Time.current.to_i)}' #{img_args.collect do |k, v|
34
- "#{k}='#{v}'"
35
- end.join(' ')} />"
36
- input = "<input type='text' name='captcha' #{input_args.collect { |k, v| "#{k}='#{v}'" }.join(' ')} />"
22
+ img_args[:onclick] = "this.src = \"#{cama_captcha_url(len: len)}\"+\"&t=\"+(new Date().getTime());"
23
+ # Keep a caller-supplied style; the pointer cursor is required for click-to-refresh, so prepend it.
24
+ img_args[:style] = ['cursor: pointer;', img_args[:style].presence].compact.join(' ')
25
+
26
+ helpers = ActionController::Base.helpers
27
+ img = helpers.image_tag(cama_captcha_url(len: len, t: Time.current.to_i), img_args)
28
+ input = helpers.tag(:input, type: 'text', name: 'captcha', **input_args)
29
+
37
30
  if bootstrap_group_mode
38
- "<div class='input-group input-group-captcha'><span class='input-group-btn' style='vertical-align: top;'>#{img}</span>#{input}</div>"
31
+ span = helpers.content_tag(:span, img, class: 'input-group-btn', style: 'vertical-align: top;')
32
+ helpers.content_tag(:div, helpers.safe_join([span, input]), class: 'input-group input-group-captcha')
39
33
  else
40
- "<div class='input-group-captcha'>#{img}#{input}</div>"
34
+ helpers.content_tag(:div, helpers.safe_join([img, input]), class: 'input-group-captcha')
41
35
  end
42
36
  end
43
37
 
44
- # verify captcha value
38
+ # verify captcha value against the single active challenge and consume it on success, so a solved
39
+ # captcha is single-use and a blank submission can never match (H3).
45
40
  def cama_captcha_verified?
46
- (session[:cama_captcha] || []).include?((params[:cama_captcha] || params[:captcha]).to_s.upcase)
41
+ submitted = (params[:cama_captcha] || params[:captcha]).to_s.upcase
42
+ return false if submitted.blank?
43
+ return false unless Array(session[:cama_captcha]).include?(submitted)
44
+
45
+ session.delete(:cama_captcha)
46
+ true
47
47
  end
48
48
 
49
49
  # ************************* captcha in attack helpers ***************************#
50
+ # Failed attempts are counted BOTH per session and per client IP. The per-IP counter lives in
51
+ # Rails.cache and is the server-side signal an attacker cannot reset by dropping the session
52
+ # cookie (audit finding H1); it rolls off CAMA_ATTACK_WINDOW after the last failure.
53
+ CAMA_ATTACK_WINDOW = 15.minutes
54
+
50
55
  # check if the current visitor was submitted 5+ times
51
56
  # key: a string to represent a url or form view
52
57
  # key must be the same as the form "captcha_tags_if_under_attack(key, ...)"
53
58
  def cama_captcha_under_attack?(key)
54
59
  session["cama_captcha_#{key}"] ||= 0
55
- session["cama_captcha_#{key}"].to_i > current_site.get_option('max_try_attack', 5).to_i
60
+ max = current_site.get_option('max_try_attack', 5).to_i
61
+ session["cama_captcha_#{key}"].to_i > max || cama_captcha_attack_ip_count(key) > max
56
62
  end
57
63
 
58
- # verify captcha values if this key is under attack
59
- # key: a string to represent a url or form view
64
+ # per-IP failed-attempt count for this key (0 when the counter is unset). Reads raw so the
65
+ # value round-trips as a bare integer on Redis/Memcached (see cama_captcha_increment_attack).
66
+ # Assumes a request + current_site context, which the admin login flow always has.
67
+ def cama_captcha_attack_ip_count(key)
68
+ Rails.cache.read(cama_captcha_attack_ip_key(key), raw: true).to_i
69
+ end
70
+
71
+ # verify the captcha only when this key is under attack; reports solely whether the
72
+ # current challenge was solved. The attack counter is cleared only by an explicit
73
+ # cama_captcha_reset_attack once the protected action itself succeeds (as the login
74
+ # flows do), so solving a captcha can no longer buy captcha-free attempts for an
75
+ # otherwise failing action. When the key is not under attack, the pending challenge
76
+ # is left unconsumed for whatever form it belongs to.
60
77
  def captcha_verify_if_under_attack(key)
61
- res = cama_captcha_under_attack?(key) ? cama_captcha_verified? : true
62
- session["cama_captcha_#{key}"] = 0 if cama_captcha_verified?
63
- res
78
+ return true unless cama_captcha_under_attack?(key)
79
+
80
+ cama_captcha_verified?
64
81
  end
65
82
 
66
- # increment attempts for key by 1
83
+ # increment attempts for key by 1 (both the per-session and the per-IP counter)
67
84
  def cama_captcha_increment_attack(key)
68
85
  session["cama_captcha_#{key}"] ||= 0
69
86
  session["cama_captcha_#{key}"] = session["cama_captcha_#{key}"].to_i + 1
87
+ # Per-IP counter: an ATOMIC cache increment, not a read-then-write. Concurrent failures from
88
+ # one IP would otherwise race on read+write and lose updates, undercounting a burst and
89
+ # deferring both the captcha gate and the hard lockout. `raw: true` keeps the value a bare
90
+ # integer so Redis/Memcached INCR operates on it (a no-op on Memory/File stores); refreshing
91
+ # the TTL on every increment keeps the rolling window alive during a sustained attack.
92
+ cache_key = cama_captcha_attack_ip_key(key)
93
+ counted = Rails.cache.increment(cache_key, 1, expires_in: CAMA_ATTACK_WINDOW, raw: true)
94
+ # Older FileStore/MemoryStore (Rails < 7.1) return nil for a missing key instead of seeding it;
95
+ # seed it then. This one-time seed's own race is harmless (count 1 vs 2 is far from any
96
+ # threshold) and every subsequent increment is atomic.
97
+ Rails.cache.write(cache_key, 1, expires_in: CAMA_ATTACK_WINDOW, raw: true) if counted.nil?
70
98
  end
71
99
 
72
- # reset the attacks counter for key
100
+ # reset the attacks counter for key (both the per-session and the per-IP counter)
73
101
  # key: a string to represent a url or form view
74
102
  def cama_captcha_reset_attack(key)
75
103
  session["cama_captcha_#{key}"] = 0
104
+ Rails.cache.delete(cama_captcha_attack_ip_key(key))
105
+ end
106
+
107
+ # cache key for the per-IP attack counter, scoped to the site and the form key
108
+ def cama_captcha_attack_ip_key(key)
109
+ "cama_captcha_attack:#{current_site.id}:#{request.remote_ip}:#{key}"
76
110
  end
77
111
 
78
112
  # return a number of attempts for key
@@ -86,19 +120,5 @@ module CamaleonCms
86
120
  def cama_captcha_tags_if_under_attack(key, captcha_parmas = [5, {}, { class: 'form-control required' }])
87
121
  cama_captcha_tag(*captcha_parmas) if cama_captcha_under_attack?(key)
88
122
  end
89
-
90
- private
91
-
92
- # generate random string for captcha
93
- # len: length of characters, default 6
94
- def cama_rand_str(len = 6)
95
- alphabets = [('A'..'Z').to_a].flatten!
96
- alphanumerics = [('A'..'Z').to_a, ('1'..'9').to_a].flatten!
97
- str = alphabets[rand(alphabets.size)]
98
- (len.to_i - 1).times do
99
- str << alphanumerics[rand(alphanumerics.size)]
100
- end
101
- str
102
- end
103
123
  end
104
124
  end
@@ -17,7 +17,10 @@ module CamaleonCms
17
17
 
18
18
  # render as html content all comments recursively
19
19
  # comments: collection of comments
20
- def cama_comments_render_html(comments)
20
+ def cama_comments_render_html(comments, post_id = nil)
21
+ if post_id.nil? && respond_to?(:controller) && controller.respond_to?(:instance_variable_get)
22
+ post_id = controller.instance_variable_get(:@post)&.id
23
+ end
21
24
  comments.decorate.map do |comment|
22
25
  author = comment.the_author
23
26
  content_tag(:div, class: 'media') do
@@ -31,7 +34,10 @@ module CamaleonCms
31
34
  [
32
35
  content_tag(:h4, class: 'media-heading') do
33
36
  [
34
- author.the_name,
37
+ # Escape the author name: User first_name/last_name are no longer sanitized at
38
+ # save time and this heading is marked html_safe below, so a commenter-controlled
39
+ # name would otherwise be injected as raw HTML into the moderator's page.
40
+ ERB::Util.html_escape(author.the_name),
35
41
  ' ',
36
42
  content_tag(:small, comment.the_created_at),
37
43
  ' ',
@@ -45,7 +51,7 @@ module CamaleonCms
45
51
  content_tag(:div, class: 'pull-left') do
46
52
  [
47
53
  link_to(
48
- cama_admin_post_comment_answer_path(@post.id, comment.id),
54
+ cama_admin_post_comment_answer_path(post_id, comment.id),
49
55
  data: { comment_id: comment.id },
50
56
  title: t('camaleon_cms.admin.comments.tooltip.reply_comment'),
51
57
  class: 'btn btn-info reply btn-xs ajax_modal'
@@ -64,20 +70,26 @@ module CamaleonCms
64
70
  [
65
71
  link_to(
66
72
  url_for({ action: :toggle_status, comment_id: comment.id, s: 'a' }),
73
+ method: :patch,
67
74
  title: t('camaleon_cms.admin.comments.tooltip.approved_comment'),
68
- class: "#{comment.approved == 'approved' ? 'hidden' : ''} btn btn-success approve btn-xs cama_ajax_request"
75
+ class: "#{comment.approved == 'approved' ? 'hidden' : ''} " \
76
+ 'btn btn-success approve btn-xs cama_ajax_request'
69
77
  ) { content_tag(:span, '', class: 'fa fa-thumbs-o-up') },
70
78
  ' ',
71
79
  link_to(
72
80
  url_for({ action: :toggle_status, comment_id: comment.id, s: 'p' }),
81
+ method: :patch,
73
82
  title: t('camaleon_cms.admin.comments.tooltip.comment_pending'),
74
- class: "#{comment.approved == 'pending' ? 'hidden' : ''} btn btn-primary pending btn-xs cama_ajax_request"
83
+ class: "#{comment.approved == 'pending' ? 'hidden' : ''} " \
84
+ 'btn btn-primary pending btn-xs cama_ajax_request'
75
85
  ) { content_tag(:span, '', class: 'fa fa-warning') },
76
86
  ' ',
77
87
  link_to(
78
88
  url_for({ action: :toggle_status, comment_id: comment.id, s: 's' }),
89
+ method: :patch,
79
90
  title: t('camaleon_cms.admin.comments.tooltip.comment_spam'),
80
- class: "#{comment.approved == 'spam' ? 'hidden' : ''} btn btn-danger spam btn-xs cama_ajax_request"
91
+ class: "#{comment.approved == 'spam' ? 'hidden' : ''} " \
92
+ 'btn btn-danger spam btn-xs cama_ajax_request'
81
93
  ) { content_tag(:span, '', class: 'fa fa-bug') }
82
94
  ].join.html_safe
83
95
  end
@@ -85,7 +97,7 @@ module CamaleonCms
85
97
  end,
86
98
  content_tag(:hr),
87
99
  content_tag(:div, '', class: 'clearfix'),
88
- cama_comments_render_html(comment.children)
100
+ cama_comments_render_html(comment.children, post_id)
89
101
  ].join.html_safe
90
102
  end
91
103
  ].join.html_safe
@@ -2,34 +2,41 @@ module CamaleonCms
2
2
  module ContentHelper
3
3
  # initialize content variables
4
4
  def cama_content_init
5
- @_before_content = []
6
- @_after_content = []
5
+ state = cama_content_state
6
+ state[:before_content] = []
7
+ state[:after_content] = []
7
8
  end
8
9
 
9
10
  # prepend content for admin or frontend (after <body>)
10
11
  # sample: cama_content_prepend(<div>my prepend content</div>)
11
12
  def cama_content_prepend(content)
12
- @_before_content << content
13
+ cama_content_state[:before_content] << content
13
14
  end
14
15
 
15
16
  # append content for admin or frontend (before </body>)
16
17
  # sample: cama_content_prepend(<div>my after content</div>)
17
18
  def cama_content_append(content)
18
- @_after_content << content
19
+ cama_content_state[:after_content] << content
19
20
  end
20
21
 
21
22
  # draw all before contents registered by cama_content_prepend
22
23
  def cama_content_before_draw
23
- @_before_content.join('')
24
+ cama_content_state[:before_content].join('')
24
25
  rescue StandardError
25
26
  ''
26
27
  end
27
28
 
28
29
  # draw all after contents registered by cama_content_append
29
30
  def cama_content_after_draw
30
- @_after_content.join('')
31
+ cama_content_state[:after_content].join('')
31
32
  rescue StandardError
32
33
  ''
33
34
  end
35
+
36
+ private
37
+
38
+ def cama_content_state
39
+ CurrentRequest.content_helper_state ||= { before_content: [], after_content: [] }
40
+ end
34
41
  end
35
42
  end
@@ -1,7 +1,7 @@
1
1
  module CamaleonCms
2
2
  module EmailHelper
3
3
  include CamaleonCms::HooksHelper
4
- # send and email
4
+ # send an email
5
5
  # email: email to
6
6
  # subject: Subject of the email
7
7
  # content: content of the email
@@ -20,7 +20,8 @@ module CamaleonCms
20
20
  end
21
21
 
22
22
  # short method of send_email
23
- # args: content='', from=nil, attachs=[], url_base='', current_site, template_name, layout_name, extra_data, format, cc_to
23
+ # args: content='', from=nil, attachs=[], url_base='', current_site, template_name, layout_name, extra_data,
24
+ # format, cc_to
24
25
  def cama_send_email(email_to, subject, args = {})
25
26
  args = { url_base: cama_root_url, current_site: current_site, subject: subject }.merge(args)
26
27
  args[:attachments] = args[:attachs] if args[:attachs].present?
@@ -42,7 +43,10 @@ module CamaleonCms
42
43
 
43
44
  def send_password_reset_email(user_to_send)
44
45
  user_to_send.send_password_reset
45
- reset_url = cama_admin_forgot_url({ h: user_to_send.password_reset_token })
46
+ # Read the column, not the same-named method has_secure_password generates on Rails 7.1+ (which
47
+ # shadows it with a signed token the controller's column lookup never matches). read_attribute
48
+ # returns the stored column on every supported Rails version (6.1–8.1).
49
+ reset_url = cama_admin_forgot_url({ h: user_to_send.read_attribute(:password_reset_token) })
46
50
  extra_data = {
47
51
  url: reset_url,
48
52
  fullname: user_to_send.fullname,
@@ -8,8 +8,13 @@ module CamaleonCms
8
8
 
9
9
  # add where conditionals to filter private/hidden/expired/drafts/unpublished
10
10
  # note: only for post records
11
- def verify_front_visibility(active_record)
11
+ # eager: collection listings (the_posts/the_contents) preload metas, categories, post_tags and
12
+ # the post-type metas up front to avoid N+1. Single-record lookups (the_post, render_post,
13
+ # the_next_post/the_prev_post) pass eager: false so a one-row find is not charged the listing
14
+ # preloads it never reads.
15
+ def verify_front_visibility(active_record, eager: true)
12
16
  active_record = active_record.visible_frontend
17
+ active_record = active_record.with_eager if eager
13
18
  r = { active_record: active_record }
14
19
  hooks_run('filter_post', r)
15
20
  r[:active_record]
@@ -1,21 +1,22 @@
1
1
  # Camaleon CMS is a content management system
2
2
  # Copyright (C) 2015 by Owen Peredo Diaz
3
3
  # Email: owenperedo@gmail.com
4
- # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
- # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
4
+ # This program is free software: you can redistribute it and/or modify it under the terms of the
5
+ # GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License,
6
+ # or (at your option) any later version.
7
+ # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
8
+ # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
6
9
  # See the GNU Affero General Public License (GPLv3) for more details.
7
10
  module CamaleonCms
8
11
  module Frontend
9
12
  module ContentSelectHelper
10
- # select single post of post type
13
+ # select a single post of post type
11
14
  # the_post_type('post') do
12
15
  # the_post('first-blog-post')
13
16
  # end
14
17
  def the_post(slug)
15
- post = @object.the_post(slug)
16
- process_in_block(post) do
17
- yield(post) if block_given?
18
- end
18
+ post = camaleon_frontend_object.the_post(slug)
19
+ process_in_block(post) { yield(post) if block_given? }
19
20
  post
20
21
  end
21
22
 
@@ -28,7 +29,7 @@ module CamaleonCms
28
29
  # the_posts(limit: 10)
29
30
  # end
30
31
  def the_posts(options = {})
31
- @object.posts.visible_frontend.limit(options[:limit]).decorate
32
+ camaleon_frontend_object.posts.visible_frontend.limit(options[:limit]).decorate
32
33
  end
33
34
 
34
35
  # select post type by just pass slug to parameter
@@ -52,7 +53,7 @@ module CamaleonCms
52
53
  # the_comments
53
54
  # end
54
55
  def the_comments(options = {})
55
- @object.comments.limit(options[:limit]).decorate if @object.present?
56
+ camaleon_frontend_object.comments.limit(options[:limit]).decorate if camaleon_frontend_object.present?
56
57
  end
57
58
 
58
59
  # select title of post
@@ -60,7 +61,7 @@ module CamaleonCms
60
61
  # the_title
61
62
  # end
62
63
  def the_title
63
- @object.the_title if @object.present?
64
+ camaleon_frontend_object&.the_title
64
65
  end
65
66
 
66
67
  # select content of post
@@ -68,7 +69,12 @@ module CamaleonCms
68
69
  # the_content
69
70
  # end
70
71
  def the_content
71
- sanitize(@object.the_content) if @object.present?
72
+ # Security (scan-and-reject policy): no render-time transform. Stored content is gated at
73
+ # save (Post#reject_untrusted_dangerous_content), always equals what its author wrote, and
74
+ # renders verbatim — the same contract as the templates' `raw post.the_content`. The
75
+ # sanitize this once applied used the narrow default allowlist, so it also broke trusted
76
+ # authors' tables and embeds on this DSL path only.
77
+ camaleon_frontend_object.the_content.to_s.html_safe if camaleon_frontend_object.present? # rubocop:disable Rails/OutputSafety
72
78
  end
73
79
 
74
80
  # select url of post
@@ -76,7 +82,7 @@ module CamaleonCms
76
82
  # the_url
77
83
  # end
78
84
  def the_url
79
- @object.the_url if @object.present?
85
+ camaleon_frontend_object&.the_url
80
86
  end
81
87
 
82
88
  # select thumbnail of post
@@ -84,15 +90,15 @@ module CamaleonCms
84
90
  # the_thumbnail
85
91
  # end
86
92
  def the_thumbnail
87
- @object.the_thumb_url if @object.present?
93
+ camaleon_frontend_object&.the_thumb_url
88
94
  end
89
95
 
90
- # select slug of post, post type ... (@object)
96
+ # select slug of post, post type ...
91
97
  # the_post('blog') do
92
98
  # the_slug
93
99
  # end
94
100
  def the_slug
95
- @object.the_slug if @object.present?
101
+ camaleon_frontend_object&.the_slug
96
102
  end
97
103
 
98
104
  # select excerpt of post
@@ -100,7 +106,7 @@ module CamaleonCms
100
106
  # the_excerpt
101
107
  # end
102
108
  def the_excerpt(chars = 200)
103
- @object.the_excerpt(chars) if @object.present?
109
+ camaleon_frontend_object&.the_excerpt(chars)
104
110
  end
105
111
 
106
112
  # select custome field from object
@@ -108,7 +114,7 @@ module CamaleonCms
108
114
  # the_field('extra-content')
109
115
  # end
110
116
  def the_field(slug)
111
- @object.the_field(slug) if @object.present?
117
+ camaleon_frontend_object&.the_field(slug)
112
118
  end
113
119
 
114
120
  # loop through each post of post type
@@ -152,10 +158,18 @@ module CamaleonCms
152
158
  # the_field('extra-content')
153
159
  # end
154
160
  def process_in_block(object)
155
- temp_object = @object
156
- @object = object
161
+ temp_object = CurrentRequest.frontend_object
162
+ CurrentRequest.frontend_object = object
157
163
  yield
158
- @object = temp_object
164
+ ensure
165
+ CurrentRequest.frontend_object = temp_object
166
+ end
167
+
168
+ private
169
+
170
+ def camaleon_frontend_object
171
+ # Fall back to a plugin-set @object ivar; core writes CurrentRequest.frontend_object. Regression M20.
172
+ CurrentRequest.frontend_object || (instance_variable_get(:@object) if instance_variable_defined?(:@object))
159
173
  end
160
174
  end
161
175
  end