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
@@ -1,9 +1,10 @@
1
1
  module CamaleonCms
2
2
  module HtmlHelper
3
3
  def cama_html_helpers_init
4
- @_pre_assets_content = [] # Assets contents before the libraries import
5
- @_assets_libraries = {}
6
- @_assets_content = []
4
+ state = camaleon_html_helper_state
5
+ state[:pre_assets_content] = [] # Assets contents before the libraries import
6
+ state[:assets_libraries] = {}
7
+ state[:assets_content] = []
7
8
  end
8
9
 
9
10
  # register a new asset library to be included on demand calling by: cama_load_libraries(...)
@@ -12,36 +13,47 @@ module CamaleonCms
12
13
  def cama_assets_library_register(key, assets = {})
13
14
  key = key.to_sym
14
15
  cama_assets_libraries
15
- @_cama_assets_libraries[key] = { css: [], js: [] } unless @_cama_assets_libraries[key].present?
16
- @_cama_assets_libraries[key][:css] += assets[:css] if assets[:css].present?
17
- @_cama_assets_libraries[key][:js] += assets[:js] if assets[:js].present?
16
+ state = camaleon_html_helper_state
17
+ state[:cama_assets_libraries][key] = { css: [], js: [] } if state[:cama_assets_libraries][key].blank?
18
+ state[:cama_assets_libraries][key][:css] += assets[:css] if assets[:css].present?
19
+ state[:cama_assets_libraries][key][:js] += assets[:js] if assets[:js].present?
18
20
  end
19
21
 
20
22
  # enable to load admin or registered libraries (colorpicker, datepicker, tinymce, form_ajax, cropper)
21
23
  # sample: add_asset_library("datepicker", "colorpicker")
22
24
  # This will add this assets library in the admin head or in a custom place by calling: cama_draw_custom_assets()
23
25
  def cama_load_libraries(*keys)
26
+ state = camaleon_html_helper_state
24
27
  keys.each do |key|
25
28
  library = cama_assets_libraries[key.to_sym]
26
- @_assets_libraries[key.to_sym] = library if library.present?
29
+ state[:assets_libraries][key.to_sym] = library if library.present?
27
30
  end
28
31
  end
29
32
 
30
33
  alias add_asset_library cama_load_libraries
31
34
 
32
- # add custom asset libraries (js, css or both) for the current request, also you can add extra css or js files for existent libraries
33
- # sample: (add new library)
34
- # append_asset_libraries({"my_library_key"=> { js: [plugin_asset("js/my_js"), "plugins/myplugin/assets/js/my_js2"], css: [plugin_asset("css/my_css"), "plugins/myplugin/assets/css/my_css2"] }})
35
+ # add custom asset libraries (js, css or both) for the current request, also you can add extra css or js files for
36
+ # existent libraries
37
+ # sample: (add a new library)
38
+ # append_asset_libraries(
39
+ # {
40
+ # "my_library_key"=> {
41
+ # js: [plugin_asset("js/my_js"), "plugins/myplugin/assets/js/my_js2"],
42
+ # css: [plugin_asset("css/my_css"), "plugins/myplugin/assets/css/my_css2"]
43
+ # }
44
+ # }
45
+ # )
35
46
  # sample: (update existent library)
36
47
  # append_asset_libraries({"colorpicker"=>{js: [plugin_asset("js/my_custom_js")] } })
37
48
  # return nil
38
49
  def append_asset_libraries(libraries)
50
+ state = camaleon_html_helper_state
39
51
  libraries.each do |key, library|
40
- @_assets_libraries[key.to_sym] = if @_assets_libraries.include?(key)
41
- @_assets_libraries[key.to_sym].merge(library)
42
- else
43
- library
44
- end
52
+ state[:assets_libraries][key.to_sym] = if state[:assets_libraries].include?(key)
53
+ state[:assets_libraries][key.to_sym].merge(library)
54
+ else
55
+ library
56
+ end
45
57
  end
46
58
  end
47
59
  alias cama_load_custom_assets append_asset_libraries
@@ -51,7 +63,7 @@ module CamaleonCms
51
63
  # content may be: <style>a{color: red;}</style>
52
64
  # this will be printed with <%raw cama_draw_custom_assets %>
53
65
  def append_asset_content(content)
54
- @_assets_content << content
66
+ camaleon_html_helper_state[:assets_content] << content
55
67
  end
56
68
 
57
69
  # add pre asset content into custom assets
@@ -59,27 +71,30 @@ module CamaleonCms
59
71
  # content may be: <style>a{color: red;}</style>
60
72
  # this will be printed before assets_library with <%raw cama_draw_pre_asset_contents %>
61
73
  def append_pre_asset_content(content)
62
- @_pre_assets_content << content
74
+ camaleon_html_helper_state[:pre_assets_content] << content
63
75
  end
64
76
 
65
77
  # return all scripts to be executed before import the js libraries(cama_draw_custom_assets)
66
78
  def cama_draw_pre_asset_contents
67
- (@_pre_assets_content || []).join('').html_safe
79
+ # rubocop:disable Rails/OutputSafety -- Callers append trusted script/style fragments that must render as markup.
80
+ camaleon_html_helper_state[:pre_assets_content].join('').html_safe
81
+ # rubocop:enable Rails/OutputSafety
68
82
  end
69
83
 
70
84
  # return all js libraries added [aa.js, bb,js, ..]
71
85
  # def get_assets_js
72
86
  def cama_draw_custom_assets
73
- cama_html_helpers_init unless @_assets_libraries.present?
87
+ state = camaleon_html_helper_state
88
+ cama_html_helpers_init if state[:assets_libraries].blank?
74
89
  libs = []
75
- @_assets_libraries.each_value do |assets|
90
+ state[:assets_libraries].each_value do |assets|
76
91
  libs += assets[:css] if assets[:css].present?
77
92
  end
78
93
  stylesheets = libs.uniq
79
94
  css = stylesheet_link_tag(*stylesheets, media: 'all')
80
95
 
81
96
  libs = []
82
- @_assets_libraries.each_value do |assets|
97
+ state[:assets_libraries].each_value do |assets|
83
98
  libs += assets[:js] if assets[:js].present?
84
99
  end
85
100
  javascripts = libs.uniq
@@ -87,33 +102,31 @@ module CamaleonCms
87
102
 
88
103
  args = { stylesheets: stylesheets, javascripts: javascripts, js_html: js, css_html: css }
89
104
  hooks_run('draw_custom_assets', args)
90
- "#{args[:css_html]}\n#{args[:js_html]}\n#{@_assets_content.join('').html_safe}"
91
- end
105
+ # rubocop:disable Rails/OutputSafety -- Asset helper output and appended fragments are trusted framework/plugin markup.
106
+ trusted_fragments = [args[:css_html], args[:js_html], *state[:assets_content]].filter_map do |fragment|
107
+ next if fragment.blank?
92
108
 
93
- # return category tree for category dropdown
94
- # each level is prefixed with -
95
- # level: internal recursive control
96
- def cama_get_options_html_from_items(terms, level = 0)
97
- options = []
98
- terms.all.each do |term|
99
- options << [('—' * level) + term.name, term.id] unless @term.id == term.id
100
- children = term.children
101
- options += cama_get_options_html_from_items(children, level + 1) unless children.empty?
109
+ fragment.is_a?(ActiveSupport::SafeBuffer) ? fragment : fragment.to_s.html_safe
102
110
  end
103
- options
111
+ # rubocop:enable Rails/OutputSafety
112
+
113
+ safe_join(trusted_fragments, "\n")
104
114
  end
105
115
 
106
- # create a html tooltip to include anywhere
116
+ # create an HTML tooltip to include anywhere
107
117
  # text: text of the tooltip
108
118
  # location: location of the tooltip (left | right | top |bottom)
109
119
  def cama_html_tooltip(text = 'Tooltip', location = 'left')
110
- "<a href='javascript:;' title='#{text}' data-toggle='tooltip' data-placement='#{location}'><i class='fa fa-info-circle'></i></a>"
120
+ tag.a(href: 'javascript:;', title: text, data: { toggle: 'tooltip', placement: location }) do
121
+ tag.i(class: 'fa fa-info-circle')
122
+ end
111
123
  end
112
124
 
113
125
  private
114
126
 
115
127
  def cama_assets_libraries
116
- return @_cama_assets_libraries if @_cama_assets_libraries.present?
128
+ state = camaleon_html_helper_state
129
+ return state[:cama_assets_libraries] if state[:cama_assets_libraries].present?
117
130
 
118
131
  libs = {}
119
132
  libs[:colorpicker] =
@@ -135,7 +148,15 @@ module CamaleonCms
135
148
  js: %w[camaleon_cms/admin/jquery.nestable camaleon_cms/admin/nav_menu] }
136
149
  libs[:admin_intro] =
137
150
  { js: ['camaleon_cms/admin/introjs/intro.min'], css: ['camaleon_cms/admin/introjs/introjs.min'] }
138
- @_cama_assets_libraries = libs
151
+ state[:cama_assets_libraries] = libs
152
+ end
153
+
154
+ def camaleon_html_helper_state
155
+ state = CurrentRequest.html_helper_state ||= {}
156
+ state[:pre_assets_content] ||= []
157
+ state[:assets_libraries] ||= {}
158
+ state[:assets_content] ||= []
159
+ state
139
160
  end
140
161
 
141
162
  # execute translation for value if this value is like: t(admin.my_text) ==> My Text
@@ -2,21 +2,19 @@ module CamaleonCms
2
2
  module PluginsHelper
3
3
  include CamaleonCms::SiteHelper
4
4
  # load all plugins + theme installed for current site
5
- # METHOD IGNORED (is a partial solution to avoid load helpers and cache it for all sites)
6
- # this method try to load helpers for each request without caching
5
+ # METHOD IGNORED (is a partial solution to avoid loading helpers and cache it for all sites)
6
+ # this method tries to load helpers for each request without caching
7
7
  def plugins_initialize(klass = nil)
8
8
  mod = Module.new
9
9
  PluginRoutes.enabled_apps(current_site).each do |plugin|
10
- next if !plugin.present? || !plugin['helpers'].present?
10
+ next if plugin.blank? || plugin['helpers'].blank?
11
11
 
12
- plugin['helpers'].each do |h|
13
- mod.send :include, h.constantize
14
- end
12
+ plugin['helpers'].each { |h| mod.send :include, h.constantize }
15
13
  end
16
14
  (klass || self).send :extend, mod
17
15
  end
18
16
 
19
- # upgrade installed plugin in current site for a new version
17
+ # upgrade the installed plugin in the current site for a new version
20
18
  # plugin_key: key of the plugin
21
19
  # trigger hook "on_upgrade"
22
20
  # return model of the plugin
@@ -29,7 +27,7 @@ module CamaleonCms
29
27
  plugin_model
30
28
  end
31
29
 
32
- # install a plugin for current site
30
+ # install a plugin for the current site
33
31
  # plugin_key: key of the plugin
34
32
  # return model of the plugin
35
33
  def plugin_install(plugin_key)
@@ -50,7 +48,7 @@ module CamaleonCms
50
48
  end
51
49
  end
52
50
 
53
- # uninstall a plugin from current site
51
+ # uninstall a plugin from the current site
54
52
  # plugin_key: key of the plugin
55
53
  # return model of the plugin
56
54
  def plugin_uninstall(plugin_key)
@@ -66,7 +64,7 @@ module CamaleonCms
66
64
  plugin_model
67
65
  end
68
66
 
69
- # remove a plugin from current site
67
+ # remove a plugin from the current site
70
68
  # plugin_key: key of the plugin
71
69
  # return model of the plugin removed
72
70
  # DEPRECATED: PLUGINS AND THEMES CANNOT BE DESTROYED
@@ -105,8 +103,10 @@ module CamaleonCms
105
103
  # return plugin full asset path
106
104
  # plugin_key: plugin name
107
105
  # asset: (String) asset name
108
- # sample: <script src="<%= plugin_asset_path("admin.js") %>"></script> => /assets/plugins/my_plugin/admin-54505620f.js
109
- # sample: <script src="<%= plugin_asset_path("admin.js", 'my_plugin') %>"></script> => /assets/plugins/my_plugin/admin-54505620f.js
106
+ # sample: <script src="<%= plugin_asset_path("admin.js") %>"></script> =>
107
+ # /assets/plugins/my_plugin/admin-54505620f.js
108
+ # sample: <script src="<%= plugin_asset_path("admin.js", 'my_plugin') %>"></script> =>
109
+ # /assets/plugins/my_plugin/admin-54505620f.js
110
110
  def plugin_asset_path(asset, plugin_key = nil)
111
111
  if plugin_key.present? && plugin_key.include?('/')
112
112
  return plugin_asset_url(plugin_key,
@@ -123,11 +123,12 @@ module CamaleonCms
123
123
  alias plugin_asset plugin_asset_path
124
124
  alias plugin_gem_asset plugin_asset_path
125
125
 
126
- # return the full url for asset of current plugin:
126
+ # return the full url for asset of the current plugin:
127
127
  # asset: (String) asset name
128
128
  # plugin_key: (optional) plugin name, default (current plugin caller to this function)
129
129
  # sample:
130
- # plugin_asset_url("css/main.css") => return: https://myhost.com/assets/plugins/my_plugin/assets/css/main-54505620f.css
130
+ # plugin_asset_url("css/main.css") =>
131
+ # return: https://myhost.com/assets/plugins/my_plugin/assets/css/main-54505620f.css
131
132
  def plugin_asset_url(asset, plugin_key = nil)
132
133
  key = plugin_key || self_plugin_key(1)
133
134
  p = PluginRoutes.plugin_info(key)['gem_mode'] ? "plugins/#{key}/#{asset}" : "plugins/#{key}/assets/#{asset}"
@@ -138,22 +139,22 @@ module CamaleonCms
138
139
  end
139
140
  end
140
141
 
141
- # auto load all helpers of this plugin
142
+ # Autoload all helpers of this plugin
142
143
  def plugin_load_helpers(plugin)
143
- return if !plugin.present? || !plugin['helpers'].present?
144
+ return if plugin.blank? || plugin['helpers'].blank?
144
145
 
145
146
  plugin['helpers'].each do |h|
146
147
  next if self.class.include?(h.constantize)
147
148
 
148
- class_eval do
149
- include h.constantize
150
- end
149
+ class_eval { include h.constantize }
151
150
  rescue StandardError => e
152
- Rails.logger.debug "Camaleon CMS - App loading error for #{h}: #{e.message}. Please check the plugins and themes presence"
151
+ Rails.logger.debug do
152
+ "Camaleon CMS - App loading error for #{h}: #{e.message}. Please check the plugins and themes presence"
153
+ end
153
154
  end
154
155
  end
155
156
 
156
- # return plugin key for current plugin file (helper|controller|view)
157
+ # return the plugin key for the current plugin file (helper|controller|view)
157
158
  # index: internal control (ignored)
158
159
  def self_plugin_key(index = 0)
159
160
  f = caller[index]
@@ -175,9 +176,7 @@ module CamaleonCms
175
176
  # return the plugin model for current site calculated according to the file caller location
176
177
  def current_plugin(plugin_key = nil)
177
178
  _key = plugin_key || self_plugin_key(1)
178
- cama_cache_fetch("current_plugin_#{_key}") do
179
- current_site.get_plugin(_key)
180
- end
179
+ cama_cache_fetch("current_plugin_#{_key}") { current_site.get_plugin(_key) }
181
180
  end
182
181
  end
183
182
  end
@@ -6,10 +6,22 @@ module CamaleonCms
6
6
  # redirect_url (default nil): after initialized the session, this will be redirected to
7
7
  # "redirect_url" if defined
8
8
  # it doesn't redirect if redirect_url === false
9
- # return to previous page if defined the cookie['return_to'] or login url received extra param: return_to=https://mysite.com
10
- def login_user(user, remember_me = false, redirect_url = nil)
11
- c = { value: [user.auth_token, request.user_agent, request.ip], expires: 24.hours.from_now }
12
- c[:domain] = :all if PluginRoutes.system_info['users_share_sites'].present? && CamaleonCms::Site.count > 1
9
+ # return to previous page if defined the cookie['return_to'], or login
10
+ # url received extra param: return_to=https://mysite.com
11
+ # allow_external (default false): follow redirect_url even when it points off-site, for a caller that
12
+ # has vetted the destination (e.g. an after_login hook doing SSO/payment). http(s) only; a configured
13
+ # allowlist (redirect_allowed_hosts option / safe_redirect_hosts hook) is honored regardless of it.
14
+ def login_user(user, remember_me = false, redirect_url = nil, rotate_session: true, allow_external: false)
15
+ # Rotate the session on a genuine sign-in (H6): a fresh session drops any state left behind by a
16
+ # previous occupant of a shared browser — notably an impersonation parent_auth_token, which
17
+ # session_back_to_parent would otherwise restore on logout, escalating the new user to admin.
18
+ # Impersonation manages that token itself and passes rotate_session: false so this does not wipe it.
19
+ reset_session if rotate_session
20
+
21
+ # Security (audit 2026-08-11 M3): the auth cookie is HttpOnly (JavaScript/XSS cannot read the
22
+ # bearer token) and Secure over an SSL request (never sent in the clear). The three components are
23
+ # joined into one string so the split-based reader and the impersonation stash keep their shape.
24
+ c = cama_auth_cookie_options([user.auth_token, request.user_agent, request.ip].join('&'))
13
25
  c[:expires] = 1.month.from_now if remember_me
14
26
 
15
27
  # fix to overwrite a cookie
@@ -24,9 +36,13 @@ module CamaleonCms
24
36
  return if redirect_url == false
25
37
 
26
38
  if redirect_url.present?
27
- redirect_to redirect_url
39
+ # Host-check the explicit redirect_url too: after_login hooks and downstream plugins pass
40
+ # caller-controlled destinations here (e.g. a return_to cookie), so it gets the same open-redirect
41
+ # guard as the return_to cookie branch below. allow_external lets such a hook opt a vetted off-site
42
+ # destination (SSO/payment) past the same-host rule; it stays http(s)-only either way.
43
+ cama_safe_redirect(redirect_url, cama_admin_dashboard_path, allow_external: allow_external)
28
44
  elsif (return_to = cookies.delete(:return_to)).present?
29
- redirect_to safe_redirect_url(return_to) || cama_admin_dashboard_path
45
+ cama_safe_redirect(return_to, cama_admin_dashboard_path)
30
46
  else
31
47
  redirect_to cama_admin_dashboard_path
32
48
  end
@@ -35,13 +51,14 @@ module CamaleonCms
35
51
  # login a user using username and password
36
52
  # return boolean: true => authenticated, false => authentication failed
37
53
  def login_user_with_password(username, password)
38
- @user = current_site.users.find_by_username(username)
39
- r = { user: @user, params: params, password: password, captcha_validate: true }
54
+ # Custom class finder: usernames are stored downcased, so the lookup must
55
+ # lower-case both sides regardless of DB collation.
56
+ user = current_site.users.find_by_username(username) # rubocop:disable Rails/DynamicFindBy
57
+ r = { user: user, params: params, password: password, captcha_validate: true }
40
58
  hooks_run('user_before_login', r)
41
- @user&.authenticate(password)
59
+ user&.authenticate(password)
42
60
  end
43
61
 
44
- ##
45
62
  # User registration.
46
63
  #
47
64
  # user_data must contain:
@@ -50,22 +67,42 @@ module CamaleonCms
50
67
  # - username
51
68
  # - password
52
69
  # - password_confirmation
53
-
54
70
  def cama_register_user(user_data, meta)
55
- @user = current_site.users.new(user_data)
56
- r = { user: @user, params: params }
57
- hook_run('user_before_register', r)
58
-
71
+ user = current_site.users.new(user_data)
72
+
73
+ # Gate on the register captcha before running any hook, so user_before_register does not fire for
74
+ # attempts that fail the captcha. This diverges from user_before_login, which runs its hook even on a
75
+ # failed captcha and passes the result as r[:captcha_validate]; user_before_register does neither, so
76
+ # the "parity with user_before_login" below is limited to the stop_process veto. Verifying here also
77
+ # consumes the single-use captcha before the hook, so a later veto burns a solved challenge —
78
+ # harmless, since the re-rendered form issues a fresh one.
59
79
  if current_site.security_user_register_captcha_enabled? && !cama_captcha_verified?
60
- { result: false, type: :captcha_error, message: t('camaleon_cms.admin.users.message.error_captcha') }
61
- elsif @user.save
62
- @user.set_metas(meta)
63
- message = current_site.need_validate_email? ? t('camaleon_cms.admin.users.message.created_pending_validate_email') : t('camaleon_cms.admin.users.message.created')
64
- r = { user: @user, message: message, redirect_url: cama_admin_login_path }
80
+ return { result: false, type: :captcha_error,
81
+ message: t('camaleon_cms.admin.users.message.error_captcha'), user: user }
82
+ end
83
+
84
+ r = { user: user, params: params, stop_process: false }
85
+ # Broadcast (hooks_run), not hook_run: hook_run(target, name, …) takes the app as its first arg, so
86
+ # hook_run('user_before_register', r) treated the name as a plugin and silently no-op'd — the hook
87
+ # never fired. Match the sibling user_before_login/user_after_register broadcasts.
88
+ hooks_run('user_before_register', r)
89
+ # A handler may veto the registration by setting r[:stop_process] (parity with user_before_login):
90
+ # stop here without saving. The handler may surface its own reason (add to r[:user].errors, or
91
+ # render/redirect); otherwise the controller shows a generic error.
92
+ return { result: false, type: :stopped, user: user } if r[:stop_process]
93
+
94
+ if user.save
95
+ user.set_metas(meta)
96
+ message = if current_site.need_validate_email?
97
+ t('camaleon_cms.admin.users.message.created_pending_validate_email')
98
+ else
99
+ t('camaleon_cms.admin.users.message.created')
100
+ end
101
+ r = { user: user, message: message, redirect_url: cama_admin_login_path }
65
102
  hooks_run('user_after_register', r)
66
- { result: true, message: r[:message], redirect_url: r[:redirect_url] }
103
+ { result: true, message: r[:message], redirect_url: r[:redirect_url], user: user }
67
104
  else
68
- { result: false, type: :no_saved }
105
+ { result: false, type: :no_saved, user: user }
69
106
  end
70
107
  end
71
108
 
@@ -79,55 +116,87 @@ module CamaleonCms
79
116
  def session_switch_user(user, redirect_url = nil)
80
117
  return unless cama_sign_in?
81
118
 
82
- session[:parent_auth_token] = cookies[:auth_token]
83
- login_user(user, false, redirect_url)
119
+ # Start impersonation from a clean session (see login_user), then stash the admin's auth cookie.
120
+ # The stash happens AFTER the reset and login_user is told not to rotate again, so the token
121
+ # session_back_to_parent restores later survives this rotation (H6).
122
+ parent_auth_token = cookies[:auth_token]
123
+ reset_session
124
+ session[:parent_auth_token] = parent_auth_token
125
+ login_user(user, false, redirect_url, rotate_session: false)
126
+ end
127
+
128
+ # The admin who started the current impersonation, resolved from the stashed parent auth token
129
+ # (same lookup as cama_current_user). Returns nil if there is no active impersonation or the stash
130
+ # no longer resolves to a user (e.g. the admin rotated their token by changing their password).
131
+ # Callers MUST re-authenticate this user before session_back_to_parent (H6 residual): the
132
+ # impersonated session an admin holds is indistinguishable from one they abandon, so the admin's
133
+ # password is the only proof that the person returning is the admin and not a later occupant.
134
+ def cama_impersonation_parent_user
135
+ token = session[:parent_auth_token].to_s.split('&').first
136
+ return if token.blank?
137
+
138
+ current_site.users_include_admins.find_by(auth_token: token)
84
139
  end
85
140
 
86
141
  # switch current session into parent session called by session_switch_user
87
142
  # after returned into parent session, this will be redirected to redirect_url or admin dashboard
143
+ # SECURITY: only call this once the parent admin has been re-authenticated (see
144
+ # Admin::SessionsController#back_to_parent and cama_impersonation_parent_user).
88
145
  def session_back_to_parent(redirect_url = nil)
89
146
  return unless cama_sign_in? && session[:parent_auth_token].present?
90
147
 
91
- cookies[:auth_token] = session[:parent_auth_token]
148
+ cookies[:auth_token] = cama_auth_cookie_options(session[:parent_auth_token])
92
149
  session.delete(:parent_auth_token)
93
150
  redirect_to (redirect_url || cama_admin_dashboard_path), notice: 'Welcome back!'
94
151
  end
95
152
 
96
153
  # logout current user
97
154
  def cama_logout_user
155
+ # Security (audit 2026-08-11 M3): rotate the server-side token so a cookie copied before logout
156
+ # cannot be replayed afterwards. The token is per-user, so this also ends the user's other sessions.
157
+ # Audit M14: skip the rotation during impersonation -- cama_current_user is then the impersonated
158
+ # user, not the admin ending the session, and rotating their token would log the innocent user
159
+ # out of all their own devices. session_back_to_parent is the intended exit from impersonation.
160
+ cama_current_user&.cama_reset_auth_token! if session[:parent_auth_token].blank?
98
161
  cookies.delete(:auth_token, domain: :all)
99
162
  cookies.delete(:auth_token, domain: nil)
100
163
  c_data = { value: nil, expires: 24.hours.ago }
101
164
  c_data[:domain] = :all if PluginRoutes.system_info['users_share_sites'].present? && CamaleonCms::Site.count > 1
102
165
  cookies[:auth_token] = c_data
103
- redirect_to params[:return_to].present? ? params[:return_to] : cama_admin_login_path,
104
- notice: t('camaleon_cms.admin.logout.message.closed')
166
+ CurrentRequest.user = nil
167
+ CurrentRequest.user_resolved = true # the cookie is gone; don't re-resolve to a stale user
168
+ # Drop all server-side session state on logout (H6): a lingering impersonation parent_auth_token
169
+ # must not survive to be restored into a later session on a shared browser.
170
+ reset_session
171
+ cama_safe_redirect(params[:return_to], cama_admin_login_path,
172
+ notice: t('camaleon_cms.admin.logout.message.closed'))
105
173
  end
106
174
 
107
- # check if current user is already signed
175
+ # Check if the current user is already signed
108
176
  def cama_sign_in?
109
177
  !cama_current_user.nil?
110
178
  end
111
179
 
112
180
  alias signin? cama_sign_in?
113
181
 
114
- # return the role for current user
182
+ # return the role for the current user
115
183
  # if not logged in, then return 'public'
116
184
  def cama_current_role
117
185
  current_site.visitor_role
118
186
  end
119
187
 
120
- # return current user logged in
188
+ # return the current user logged in
121
189
  def cama_current_user
122
- return @cama_current_user if defined?(@cama_current_user)
123
-
124
- # api current user...
125
- @cama_current_user = cama_calc_api_current_user
126
- return @cama_current_user if @cama_current_user
127
-
128
- return nil unless cookie_auth_token_complete?
129
-
130
- @cama_current_user = current_site.users_include_admins.find_by_auth_token(user_auth_token_from_cookie).try(:decorate)
190
+ # Honor an externally-set user, and memoize a nil resolution via user_resolved so a signed-out
191
+ # request (or a present-but-stale auth cookie) is not re-resolved on every call (regression M16).
192
+ return CurrentRequest.user if CurrentRequest.user || CurrentRequest.user_resolved
193
+
194
+ CurrentRequest.user_resolved = true
195
+ user = cama_calc_api_current_user # api current user...
196
+ if user.nil? && cookie_auth_token_complete?
197
+ user = current_site.users_include_admins.find_by(auth_token: user_auth_token_from_cookie).try(:decorate)
198
+ end
199
+ CurrentRequest.user = user
131
200
  end
132
201
 
133
202
  def cookie_auth_token_complete?
@@ -159,7 +228,7 @@ module CamaleonCms
159
228
 
160
229
  # return the session id
161
230
  def cama_get_session_id
162
- session[:autor] = 'Owen Peredo Diaz' unless request.session_options[:id].present?
231
+ session[:autor] = 'Owen Peredo Diaz' if request.session_options[:id].blank?
163
232
  id = request.session_options[:id]
164
233
  id = id.public_id if id.instance_of?(::Rack::Session::SessionId)
165
234
  id
@@ -167,18 +236,93 @@ module CamaleonCms
167
236
 
168
237
  private
169
238
 
170
- # validate redirect url to prevent open redirect attacks
171
- def safe_redirect_url(url)
239
+ # Options for the auth cookie (M3): HttpOnly so JavaScript cannot read the bearer token, Secure over
240
+ # an SSL request so it is never sent in the clear, a 24h default lifetime, and the shared-domain
241
+ # scope when sites share users. Callers override :expires (remember-me) as needed.
242
+ def cama_auth_cookie_options(value)
243
+ opts = { value: value, httponly: true, secure: request.ssl?, expires: 24.hours.from_now }
244
+ opts[:domain] = :all if PluginRoutes.system_info['users_share_sites'].present? && CamaleonCms::Site.count > 1
245
+ opts
246
+ end
247
+
248
+ # validate redirect url to prevent open redirect attacks: relative, or same-host over http(s), only —
249
+ # unless the off-site host is explicitly trusted (see cama_redirect_allowed_hosts) or the caller vouches
250
+ # for it (allow_external, for hook-set SSO/payment destinations). Host compared case-insensitively
251
+ # (RFC 3986); a same-host absolute URL is emitted with the host in the request's canonical case, so
252
+ # Rails' own case-sensitive open-redirect protection stays active without tripping on it.
253
+ def safe_redirect_url(url, allow_external: false)
172
254
  return if url.blank?
173
255
 
174
256
  uri = URI.parse(url)
175
- return if uri.host.present? && uri.host != request.host
257
+ if uri.host.present?
258
+ # A non-HTTP scheme carrying a host (javascript://host/..., data://host/...) parses with a host but
259
+ # is not a real navigation — it would execute if rendered into an href/JS sink. Reject it before any
260
+ # host decision, so neither the allowlist nor allow_external can let such a scheme through.
261
+ return unless uri.scheme.blank? || %w[http https].include?(uri.scheme.downcase)
262
+
263
+ if uri.host.casecmp?(request.host)
264
+ uri.host = request.host
265
+ return uri.to_s
266
+ end
267
+
268
+ # Off-host: followed only for an admin/plugin-vetted host, or when the caller explicitly vouches for
269
+ # this destination (allow_external — a hook doing SSO/payment). Never for a caller-controlled
270
+ # return_to, which always passes allow_external: false.
271
+ return uri.to_s if allow_external || cama_redirect_host_allowed?(uri.host)
272
+
273
+ return
274
+ end
275
+
276
+ # A blank parsed host does not mean same-origin: a scheme (https:evil.com, javascript:...) or a
277
+ # slash/backslash-led form (///evil.com, /\evil.com, and their %2f/%5c encodings) still sends a
278
+ # browser off-site. Follow only a genuine same-origin path — one leading "/" then a normal path
279
+ # character (audit: the ///evil.com open redirect).
280
+ return unless uri.scheme.blank? && url.start_with?('/') && !url.match?(%r{\A/(?:[/\\]|%2f|%5c)}i)
176
281
 
177
282
  url
178
283
  rescue URI::InvalidURIError
179
284
  nil
180
285
  end
181
286
 
287
+ # Redirect to a destination vetted by safe_redirect_url, or to `fallback` when it is dropped. A vetted
288
+ # off-host destination (allowlisted or vouched via allow_external) is emitted with allow_other_host so
289
+ # Rails' backstop does not reject it; a same-host destination keeps that backstop as a second layer.
290
+ def cama_safe_redirect(url, fallback, allow_external: false, **opts)
291
+ target = safe_redirect_url(url, allow_external: allow_external)
292
+ return redirect_to(fallback, **opts) if target.blank?
293
+
294
+ opts[:allow_other_host] = true if cama_redirect_off_host?(target) && cama_supports_allow_other_host?
295
+ redirect_to(target, **opts)
296
+ end
297
+
298
+ # Admin- and plugin-declared allowlist of off-site hosts that redirects may target, in addition to the
299
+ # request host. Sourced from the `redirect_allowed_hosts` site option (comma-separated) and the
300
+ # `safe_redirect_hosts` hook (plugins append their provider host to r[:hosts]). Empty by default — the
301
+ # strict same-host policy — so an off-host redirect is only ever reachable once a host is explicitly
302
+ # trusted. Only consulted for an off-host destination, so same-host/relative redirects skip it.
303
+ def cama_redirect_allowed_hosts
304
+ r = { hosts: current_site.get_option('redirect_allowed_hosts', '').to_s.split(',') }
305
+ hooks_run('safe_redirect_hosts', r)
306
+ Array(r[:hosts]).map { |h| h.to_s.strip }.reject(&:blank?)
307
+ end
308
+
309
+ def cama_redirect_host_allowed?(host)
310
+ cama_redirect_allowed_hosts.any? { |allowed| allowed.casecmp?(host) }
311
+ end
312
+
313
+ def cama_redirect_off_host?(url)
314
+ host = URI.parse(url).host
315
+ host.present? && !host.casecmp?(request.host)
316
+ rescue URI::InvalidURIError
317
+ false
318
+ end
319
+
320
+ # allow_other_host is a Rails 7.0+ redirect_to option; on the 6.1 floor there is no open-redirect
321
+ # backstop to satisfy, so an off-host redirect there just works without it.
322
+ def cama_supports_allow_other_host?
323
+ Rails::VERSION::MAJOR >= 7
324
+ end
325
+
182
326
  # calculate the current user for API
183
327
  def cama_calc_api_current_user
184
328
  begin
@@ -190,7 +334,7 @@ module CamaleonCms
190
334
  end
191
335
  return unless doorkeeper_token
192
336
 
193
- current_site.users_include_admins.find_by_id(doorkeeper_token.resource_owner_id).try(:decorate)
337
+ current_site.users_include_admins.find_by(id: doorkeeper_token.resource_owner_id).try(:decorate)
194
338
  end
195
339
  end
196
340
  end