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
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'loofah'
4
+ require 'rails-html-sanitizer'
5
+
6
+ module CamaleonCms
7
+ # Scan-and-reject gate for authored rich text (audit findings M17 and the post-content policy).
8
+ #
9
+ # The security model is rejection, not transformation: an untrusted author's content is either
10
+ # stored exactly as written or refused with an error naming the problem — it is never silently
11
+ # rewritten. Stored content therefore always equals authored content, and the frontend may render
12
+ # it verbatim, which is what the templates do (`raw` post content, `raw` editor field values).
13
+ #
14
+ # The detector mirrors the gate cama_contact_form's admin controller applies to its authored
15
+ # markup positions (keep the two in parity): parse once, compare what the safe-list scrubber
16
+ # would remove against the parse's own reserialization, so only a genuine removal registers —
17
+ # never a spelling difference (`Tom & Jerry`, `<br/>`, quote style, tag case). Three structural
18
+ # guards cover what that comparison cannot see: markup the parser drops instead of scrubbing,
19
+ # a tag left open at the end of the value, and a translation marker sitting inside a tag (the
20
+ # renderer deletes markers, so one inside a tag splices markup the gate never saw).
21
+ module UnsafeMarkup
22
+ # Every gated value costs a parse, and both the number of values and their size are chosen by
23
+ # the caller — bound the size so a pathological multi-megabyte value cannot drive a giant Loofah
24
+ # parse. The ceiling is generous (an ordinary long article is well under it); callers surface an
25
+ # over-size refusal with its own message via `too_large?`, and `unsafe_html?` keeps the check as a
26
+ # fail-closed backstop for callers that do not pre-check.
27
+ MAX_GATED_VALUE_BYTES = 2 * 1024 * 1024
28
+
29
+ # Nothing below can find anything in a string holding none of these: the scrubber and the
30
+ # serializer are both the identity function on it. `&` counts because entity decoding is a
31
+ # rewrite, the private-use sentinels count because the translation-marker shield deletes any
32
+ # supplied raw, and the control characters count because the HTML parser rewrites them.
33
+ SANITIZER_SIGNIFICANT = /[<>&\u{E000}\u{E001}\u0000-\u0008\u000B\u000C\u000E-\u001F\u{FFFE}\u{FFFF}]/
34
+
35
+ # A well-formed translation marker (`<!--:-->`, `<!--:en-->`). Any other `<!--` is refused:
36
+ # the sanitizer strips comments, so a stray comment would register as a removal anyway, but an
37
+ # unterminated one additionally swallows everything to the next `-->` when emitted.
38
+ TRANSLATION_MARKER = /<!--:[\w|-]{0,5}-->/
39
+
40
+ # `<` only opens a tag when a name, a solidus or a markup declaration follows it — this is what
41
+ # keeps `Age < 18` and `5 > 3` out of the tag scanners below.
42
+ TAG_OPEN = %r{<[a-zA-Z/!?]}
43
+ START_TAG_NAME = %r{<([a-zA-Z][a-zA-Z0-9]*)(?=[\s/>]|\z)}
44
+ # One whole tag, quoted attribute values included, so a `>` inside an attribute does not look
45
+ # like the end of the tag; an unterminated quote runs to the end of the value.
46
+ TAG_SPAN = %r{<[a-zA-Z/!?][^"'>]*(?:(?:"[^"]*"?|'[^']*'?)[^"'>]*)*>?}
47
+
48
+ # `data-*`/`aria-*` are admitted by shape: the set is open, they carry no behaviour of their
49
+ # own (a data- attribute is inert without script; an aria- attribute only annotates), and
50
+ # without them ordinary pasted Bootstrap-style markup would be refused for attributes no
51
+ # rejection message names.
52
+ OPEN_ATTR_NAME = /\A(?:data|aria)-[a-zA-Z][-a-zA-Z0-9_.]*\z/
53
+
54
+ # rails-html-sanitizer >= 1.6 exposes the scrubber under Rails::HTML (all caps); older releases
55
+ # (valid with Rails 6.1/7.0, which this gem supports) expose only Rails::Html. Resolve whichever
56
+ # the host bundled so the engine loads across the whole supported range.
57
+ PERMIT_SCRUBBER_BASE = defined?(Rails::HTML::PermitScrubber) ? Rails::HTML::PermitScrubber : Rails::Html::PermitScrubber
58
+
59
+ class PermissiveDataAttrScrubber < PERMIT_SCRUBBER_BASE
60
+ private
61
+
62
+ def scrub_attribute?(name)
63
+ return false if name.to_s.match?(OPEN_ATTR_NAME)
64
+
65
+ super
66
+ end
67
+ end
68
+
69
+ class << self
70
+ # True when `value` contains markup outside the given allowlist (or one of the structural
71
+ # shapes above). Callers reject the save when this is true for an untrusted author.
72
+ def unsafe_html?(value, tags:, attributes:)
73
+ string = scannable_string(value)
74
+ return false unless string.match?(SANITIZER_SIGNIFICANT)
75
+ return true if string.bytesize > MAX_GATED_VALUE_BYTES
76
+ return true if disallowed_comment?(string)
77
+ return true if marker_inside_tag?(string)
78
+ return true if unterminated_tag?(string)
79
+
80
+ shielded = string.gsub(CamaleonRecord::TRANSLATION_TAG_HIDE_REGEX, CamaleonRecord::TRANSLATION_TAG_HIDE_MAP)
81
+ fragment = parse(shielded)
82
+ # The css scrubber reformats whitespace while it scrubs, so a benign `text-align: center`
83
+ # would register as a removal in the comparison below. Check style values separately
84
+ # (whitespace-insensitively) and normalize them on the tree first, so the comparison sees
85
+ # identical css on both sides and only element/attribute removals register.
86
+ return true if dangerous_or_normalized_css!(fragment)
87
+ return true if attribute_holds_markup?(fragment)
88
+
89
+ baseline = fragment.to_s
90
+ return true if markup_dropped?(shielded, fragment)
91
+
92
+ # One parse, not two: scrub the fragment already built, so both sides of the comparison
93
+ # share the same parser and serializer and only a genuine removal differs.
94
+ fragment.scrub!(scrubber_for(tags, attributes)).to_s != baseline
95
+ end
96
+
97
+ # True when the value exceeds the parse-cost ceiling. Callers refuse it with a size-specific
98
+ # message rather than the markup message — an over-size value may be perfectly clean.
99
+ def too_large?(value)
100
+ value.to_s.bytesize > MAX_GATED_VALUE_BYTES
101
+ end
102
+
103
+ # True when `value` carries a script-capable URI scheme (javascript:, vbscript:, or a
104
+ # non-raster data: URI), tolerant of the gap characters and entity encodings a browser
105
+ # strips. For values a renderer emits into a URL position (href/src).
106
+ def dangerous_uri?(value)
107
+ CamaleonCms::ContentSecurity.blocked_scheme?(value.to_s)
108
+ end
109
+
110
+ private
111
+
112
+ # Prefer the HTML5 parser (browser-parity) only when it is actually available: the method
113
+ # arrived in loofah 2.21, so guard on the method itself, not on `Nokogiri::HTML5` (present
114
+ # since nokogiri 1.11) -- an older loofah with a newer nokogiri would otherwise NoMethodError.
115
+ def parse(string)
116
+ if Loofah.respond_to?(:html5_fragment) && defined?(Nokogiri::HTML5)
117
+ Loofah.html5_fragment(string)
118
+ else
119
+ Loofah.fragment(string)
120
+ end
121
+ end
122
+
123
+ # The gate only reads, never stores. A legacy value with invalid (or non-UTF-8) encoding would
124
+ # make the `match?` below raise ArgumentError -- a 500 on save and an abort of the scan task --
125
+ # so scan a scrubbed UTF-8 copy instead. The stored bytes are never touched (reject, don't
126
+ # transform), and invalid byte sequences cannot spell markup anyway.
127
+ def scannable_string(value)
128
+ string = value.to_s
129
+ return string if string.encoding == Encoding::UTF_8 && string.valid_encoding?
130
+
131
+ string.b.force_encoding(Encoding::UTF_8).scrub
132
+ end
133
+
134
+ def scrubber_for(tags, attributes)
135
+ PermissiveDataAttrScrubber.new.tap do |scrubber|
136
+ scrubber.tags = tags
137
+ scrubber.attributes = attributes
138
+ end
139
+ end
140
+
141
+ # Scrub every style attribute with the same css scrubber the safe-list scrub uses, compare
142
+ # declaration-by-declaration ignoring the scrubber's benign rewrites (whitespace, a trailing
143
+ # semicolon), and write the scrubbed form back so the later serialization comparison cannot
144
+ # re-register it. A surviving difference means the scrubber refused part of the css
145
+ # (expression(), url(javascript:), a disallowed property) — dangerous, report it.
146
+ def dangerous_or_normalized_css!(fragment)
147
+ fragment.css('[style]').any? do |node|
148
+ original = node['style'].to_s
149
+ scrubbed = Loofah::HTML5::Scrub.scrub_css(original)
150
+ node['style'] = scrubbed
151
+ css_declarations(original) != css_declarations(scrubbed)
152
+ end
153
+ end
154
+
155
+ def css_declarations(css)
156
+ css.gsub(/\s+/, '').split(';').reject(&:empty?)
157
+ end
158
+
159
+ # Any `<!--` that is not a well-formed translation marker. A bare `-->` is ordinary text.
160
+ def disallowed_comment?(string)
161
+ string.gsub(TRANSLATION_MARKER, '').include?('<!--')
162
+ end
163
+
164
+ # A tag opened after the last `>` is never completed inside this value.
165
+ def unterminated_tag?(string)
166
+ string.rpartition('>').last.match?(TAG_OPEN)
167
+ end
168
+
169
+ # Every tag the author wrote has to appear in the parse. Compared by name rather than by
170
+ # count, because the parser *inserts* elements too — an implied `<tbody>` would otherwise
171
+ # cancel out a dropped `<td>` and let the pair through.
172
+ def markup_dropped?(shielded, fragment)
173
+ written = shielded.scan(START_TAG_NAME).flatten.map(&:downcase).tally
174
+ return false if written.empty?
175
+
176
+ parsed = fragment.css('*').map { |node| node.name.downcase }.tally
177
+ written.any? { |name, count| parsed.fetch(name, 0) < count }
178
+ end
179
+
180
+ # An attribute value that decodes to a tag-open is markup smuggled through an attribute the
181
+ # scrubber keeps -- a data-*/aria-* attribute admitted by shape, or an allowed one like `title`.
182
+ # A client-side `data-html` sink (Bootstrap tooltip/popover, and similar) injects such a value
183
+ # as HTML at render. Entity-encoded markup (`&lt;img ...&gt;`) leaves no literal `<` in the
184
+ # stored bytes, so `markup_dropped?` never sees it; but the parser has already entity-decoded
185
+ # the value on the node, so a tag-open here is real markup the renderer would emit. Reject it
186
+ # (never rewrite). A legitimate `<` in an attribute value (rare) must be entity-escaped by hand.
187
+ def attribute_holds_markup?(fragment)
188
+ fragment.css('*').any? do |node|
189
+ node.attribute_nodes.any? { |attr| attr.value.to_s.match?(TAG_OPEN) }
190
+ end
191
+ end
192
+
193
+ # A translation marker separates whole translated blocks; it never belongs *inside* a tag.
194
+ # The renderer deletes markers, so one inside a tag splices the halves into markup the
195
+ # author never wrote and this gate never saw.
196
+ def marker_inside_tag?(string)
197
+ return false unless string.include?('<!--')
198
+
199
+ tags = +''
200
+ string.scan(/#{TRANSLATION_MARKER}|#{TAG_SPAN}/) do |match|
201
+ tags << match unless match.match?(/\A#{TRANSLATION_MARKER}\z/)
202
+ end
203
+ tags.match?(TRANSLATION_MARKER)
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'stringio'
5
+
6
+ module CamaleonCms
7
+ module UploaderContentSecurity
8
+ # Extensions a browser parses as markup. These select the parse-based checker; everything else
9
+ # keeps the generic pattern ruleset.
10
+ #
11
+ # The routing key is *how the stored file will be rendered*, not a literal `.svg` comparison.
12
+ # The two are not the same thing, and the difference was the bug: identical bytes carrying an
13
+ # `onpointerdown` attribute were refused as `x.svg` and stored as `x.html`, because only the
14
+ # first name reached the checker that rejects handlers by shape.
15
+ #
16
+ # Deliberately narrow. Everything listed here is served as markup and therefore parsed as markup
17
+ # by the browser; nothing else is added "just in case". Running a markup parser over content
18
+ # that is not markup invents attributes that were never written -- `if a<b and on=1 then x` in a
19
+ # .txt parses to a `b` element with an `on` attribute -- so a wider list would refuse ordinary
20
+ # text files.
21
+ MARKUP_EXTENSIONS = %w[svg svgz svg.gz html htm xhtml xht shtml xml xsl xslt].freeze
22
+
23
+ # Of those, the ones that are not well-formed XML and need the HTML parser (see
24
+ # SvgContentChecker: the two modes differ in whether a parse failure means anything).
25
+ HTML_MODE_EXTENSIONS = %w[html htm shtml].freeze
26
+
27
+ # Gzip-compressed markup. Compressed bytes are high-entropy: no pattern matches them and no
28
+ # parser reads them, so scanning them as they arrive is not weak scanning, it is no scanning.
29
+ COMPRESSED_MARKUP_EXTENSIONS = %w[svgz svg.gz].freeze
30
+
31
+ # Executable script. Refused for untrusted uploaders rather than scanned, because no scan can
32
+ # reach a verdict on JavaScript: it has no safe subset, every dangerous capability is reachable
33
+ # through dynamic construction (`window['fet'+'ch']`), and legitimate uploaded script is
34
+ # arbitrary script -- indistinguishable from a payload by any static rule. A scanner that cannot
35
+ # decide has to fail closed, and a fail-closed scanner for these types is a permission gate.
36
+ #
37
+ # The gate is the existing `media_unfiltered_upload`, not a new permission. Its holders skip
38
+ # scanning entirely, so they could already store these files; reusing it grants nothing new and
39
+ # changes nothing for an install that has already granted it, whereas a new permission would
40
+ # revoke the capability from those installs until they granted the second one too.
41
+ SCRIPT_EXTENSIONS = %w[js mjs cjs wasm swf].freeze
42
+
43
+ # Ceiling on decompressed markup. Compression is the one case where the upload size limit stops
44
+ # bounding the work: a few-KB upload can expand to gigabytes, so the input limit says nothing
45
+ # about the memory the scan will use. Generous for a real compressed image and far below
46
+ # anything a bomb aims for; an upload that exceeds it is refused rather than expanded further.
47
+ MAX_DECOMPRESSED_MARKUP_BYTES = 10 * 1024 * 1024
48
+
49
+ # Leading bytes of a gzip member. Used to decide whether trailing bytes after one member begin
50
+ # another, so every member is decompressed rather than only the first.
51
+ GZIP_MAGIC = "\x1f\x8b".b.freeze
52
+ # Whether the current uploader may skip the malicious-content scan. Mirrors
53
+ # Post#trusted_for_unfiltered_html?: read the request context, fail closed (scan) when
54
+ # either half is missing -- background jobs, rake tasks and the console have no request
55
+ # user, and an upload from there must be scanned rather than exempted.
56
+ #
57
+ # Deliberately not memoized. The crop flow evaluates this twice, which costs two role-meta
58
+ # lookups on an operation already doing file I/O; an ivar memo would have to be invalidated
59
+ # whenever CurrentRequest changes, and the object it would hang on is sometimes a long-lived
60
+ # plugin helper rather than a per-request controller.
61
+ def cama_trusted_for_unfiltered_upload?
62
+ user = CurrentRequest.user
63
+ site = CurrentRequest.site
64
+ return false if user.blank? || site.blank?
65
+
66
+ CamaleonCms::Ability.new(user, site).can?(:manage, :media_unfiltered_upload)
67
+ rescue StandardError
68
+ # Ability#initialize dereferences the site and reads role metas for non-admin users;
69
+ # malformed meta must fail closed instead of aborting the upload with a 500.
70
+ false
71
+ end
72
+
73
+ def svg_upload?(uploaded_io)
74
+ file_path = if uploaded_io.is_a?(ActionDispatch::Http::UploadedFile)
75
+ uploaded_io.original_filename
76
+ else
77
+ uploaded_io.path
78
+ end
79
+ cama_upload_extension(file_path) == 'svg'
80
+ end
81
+
82
+ # Scans in-memory content. Callers holding decoded bytes (e.g. a base64 data:
83
+ # payload) use this to check *before* writing to a web-served staging path, so
84
+ # rejected content never reaches a servable location.
85
+ #
86
+ # Returns a truthy value (the matched pattern, or true for SVG) when unsafe,
87
+ # nil when safe -- matching file_content_unsafe?'s contract.
88
+ def content_unsafe?(content, filename: nil)
89
+ extension = cama_upload_extension(filename)
90
+
91
+ # No permission check here. Every caller reaches this method only after
92
+ # `cama_trusted_for_unfiltered_upload?` already answered false, so the uploader is known to be
93
+ # untrusted; asking again would cost a second role-meta lookup and could disagree with the
94
+ # check that already ran. It also means the fail-closed behaviour is inherited exactly --
95
+ # no request user or no site means the scan runs, and the scan refuses these.
96
+ if SCRIPT_EXTENSIONS.include?(extension)
97
+ Rails.logger.info { 'Potentially malicious content found: executable script upload' }
98
+ return 'script_upload'
99
+ end
100
+
101
+ if MARKUP_EXTENSIONS.include?(extension)
102
+ return true if markup_unsafe?(content, extension)
103
+
104
+ return nil
105
+ end
106
+
107
+ normalized = CamaleonCms::ContentSecurity.normalize(content)
108
+ if CamaleonCms::ContentSecurity.blocked_scheme_in?(normalized)
109
+ Rails.logger.info { 'Potentially malicious content found: blocked URI scheme' }
110
+ return 'blocked_scheme'
111
+ end
112
+ CamaleonCms::ContentSecurity::SUSPICIOUS_PATTERNS.each do |pattern|
113
+ next unless normalized&.match?(pattern)
114
+
115
+ Rails.logger.info { "Potentially malicious content found: #{pattern.inspect}" }
116
+ return pattern.inspect
117
+ end
118
+ nil
119
+ end
120
+
121
+ # IO-based entry point: reads the handle, rewinds it so downstream consumers
122
+ # still see the full content, and delegates to content_unsafe?.
123
+ def file_content_unsafe?(uploaded_io)
124
+ file = uploaded_io.is_a?(ActionDispatch::Http::UploadedFile) ? uploaded_io.tempfile : uploaded_io
125
+ filename = if uploaded_io.is_a?(ActionDispatch::Http::UploadedFile)
126
+ uploaded_io.original_filename
127
+ else
128
+ uploaded_io.path
129
+ end
130
+ # `.svg` is read as-is; every other extension is forced to BINARY (svgz needs the raw gzip
131
+ # bytes, and the markup parser reads the encoding from the content, not the String tag). Reuse
132
+ # the filename already computed rather than re-deriving the path through svg_upload?.
133
+ if cama_upload_extension(filename) != 'svg' && file.respond_to?(:binmode) && file.respond_to?(:set_encoding)
134
+ file.set_encoding(Encoding::BINARY)
135
+ end
136
+ content = file.read
137
+ file.rewind if file.respond_to?(:rewind)
138
+
139
+ content_unsafe?(content, filename: filename)
140
+ end
141
+
142
+ private
143
+
144
+ # The extension that selects the ruleset, lowercased. Handles three shapes a naive
145
+ # `File.extname` gets wrong, each of which was or would be a routing bypass:
146
+ # - a compound `.svg.gz`, which File.extname reports as `.gz`
147
+ # - a dotfile (`.svg`), which File.extname reports as having no extension at all
148
+ # - any case (`evil.SVG`), since the stored name keeps whatever case the client sent
149
+ # A name with no extension yields '' and takes the generic ruleset.
150
+ def cama_upload_extension(name)
151
+ base = File.basename(name.to_s).downcase
152
+ # Trailing dots and spaces do not survive the way the file is served: some servers and
153
+ # filesystems strip them (IIS-style), turning `evil.svg.` back into `evil.svg` and serving it
154
+ # as SVG, so `File.extname` reporting no extension would route it to the weaker generic ruleset
155
+ # while the browser parses it as markup. Strip them so routing matches how the file is served.
156
+ base = base.sub(/[.\s]+\z/, '')
157
+ compound = COMPRESSED_MARKUP_EXTENSIONS.find { |ext| ext.include?('.') && base.end_with?(".#{ext}") }
158
+ return compound if compound
159
+
160
+ extension = File.extname(base).delete_prefix('.')
161
+ return extension if extension.present?
162
+
163
+ # A basename that is entirely an extension (`.svg`) routes on that name rather than falling
164
+ # through to the generic ruleset -- fail closed, preserving the behaviour of the suffix check
165
+ # this replaced.
166
+ base.start_with?('.') ? base.delete_prefix('.') : ''
167
+ end
168
+
169
+ # Markup verdict: the byte-level scheme check, then the parser the browser would use.
170
+ #
171
+ # The scheme check is not redundant with the parse, and dropping it would have lost detection.
172
+ # `normalize` strips the NUL and C0 bytes a URL parser ignores inside a scheme, so
173
+ # `java\0script:` matches on the raw bytes; the HTML parser instead *truncates* the attribute at
174
+ # the NUL, leaving `java` and nothing for a parse-based check to find. Bytes and tree disagree,
175
+ # so both are consulted.
176
+ #
177
+ # The element and handler patterns still run here, as a byte-level backstop the parse cannot
178
+ # provide. A markup parser autodetects its encoding from in-band signals (a BOM, an XML
179
+ # declaration, a `<meta charset>`); a browser can resolve the same signals differently, so a
180
+ # document declaring `utf-16` while carrying ASCII fires its handlers in the browser but reaches
181
+ # the parser as garbled utf-16 with no handlers seen. `suspicious_markup_bytes?` strips the
182
+ # NUL/C0 padding and matches the element/handler tokens on the collapsed bytes, catching the
183
+ # payload whatever encoding the parser chose. It does not entity-decode, so the parse still owns
184
+ # the documented false positive it removes: a document that merely *shows* escaped markup
185
+ # (`&lt;script&gt;`) renders as literal text in a browser and is not refused for it.
186
+ def markup_unsafe?(content, extension)
187
+ scannable = cama_markup_scannable(content, extension)
188
+ return true if scannable == :too_large
189
+
190
+ return true if CamaleonCms::ContentSecurity.blocked_scheme?(scannable)
191
+ return true if CamaleonCms::ContentSecurity.suspicious_markup_bytes?(scannable)
192
+
193
+ CamaleonCms::SvgContentChecker.unsafe?(scannable, mode: cama_markup_parse_mode(extension))
194
+ end
195
+
196
+ # The bytes a browser will actually parse: the decompressed payload for a compressed markup
197
+ # extension, the content itself otherwise.
198
+ #
199
+ # Bytes under a compressed extension that are not valid gzip fall back to being scanned as raw
200
+ # markup rather than skipped. nginx's default mime map serves `.svgz` as `image/svg+xml`, so an
201
+ # ungzipped file under that name still renders as SVG; skipping it would be the same
202
+ # scanned-in-name-only hole the compression created.
203
+ def cama_markup_scannable(content, extension)
204
+ return content unless COMPRESSED_MARKUP_EXTENSIONS.include?(extension)
205
+
206
+ cama_gunzip_bounded(content) || content
207
+ end
208
+
209
+ # Decompresses every gzip member, at most MAX_DECOMPRESSED_MARKUP_BYTES in total. Returns the
210
+ # payload, `:too_large` when it exceeds the ceiling, or nil when the bytes are not gzip at all.
211
+ #
212
+ # A gzip stream may hold several concatenated members, and a compliant decoder returns their
213
+ # concatenation -- the browser serving `.svgz` with `Content-Encoding: gzip`, the gzip CLI, and
214
+ # zlib all do. `GzipReader#read` stops at the first member, so reading once would scan a clean
215
+ # decoy member while a hostile second member is served unexamined. Loop over `unused` (the bytes
216
+ # left after a member's footer) so every member is decompressed and scanned.
217
+ #
218
+ # Reads one byte past the remaining ceiling per member and checks after each, rather than reading
219
+ # to EOF and measuring afterwards: a GzipReader decompresses lazily, so a bomb is detected having
220
+ # materialized only the bound, not the gigabytes behind it.
221
+ def cama_gunzip_bounded(content)
222
+ remaining = content.to_s.b
223
+ return nil unless remaining.start_with?(GZIP_MAGIC)
224
+
225
+ decompressed = +''.b
226
+ while remaining.start_with?(GZIP_MAGIC)
227
+ reader = Zlib::GzipReader.new(StringIO.new(remaining))
228
+ begin
229
+ decompressed << reader.read(MAX_DECOMPRESSED_MARKUP_BYTES + 1 - decompressed.bytesize).to_s
230
+ remaining = reader.unused.to_s.b
231
+ ensure
232
+ reader.close
233
+ end
234
+ return :too_large if decompressed.bytesize > MAX_DECOMPRESSED_MARKUP_BYTES
235
+ end
236
+ decompressed
237
+ rescue Zlib::Error, EOFError
238
+ # A member whose footer will not verify (truncated or corrupt). Scan whatever earlier members
239
+ # already decoded -- the prefix a compliant decoder would emit before erroring -- or, if none
240
+ # did, fall back to the raw bytes, which fail closed for anything that is not clean markup.
241
+ decompressed.empty? ? nil : decompressed
242
+ end
243
+
244
+ def cama_markup_parse_mode(extension)
245
+ HTML_MODE_EXTENSIONS.include?(extension) ? :html : :xml
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Naming/MethodParameterName
4
+ module CamaleonCms
5
+ # Image transformation shared by the two uploader entry points,
6
+ # CamaleonCms::RuntimeUploaderConcern and CamaleonCms::UploaderHelper, so cropping
7
+ # and resizing behave identically whichever one a caller included.
8
+ module UploaderImageProcessing
9
+ # Terminal `.svg` extension, any case. The upload pipeline preserves the client's extension
10
+ # case (`logo.SVG` is stored verbatim), so every site renaming an SVG-derived artifact to
11
+ # `.jpg` must match case-insensitively or uppercase uploads keep their SVG name and format.
12
+ # Anchored so the rename never touches an `.svg` occurring mid-path.
13
+ SVG_EXT_PATTERN = /\.svg\z/i
14
+
15
+ # generate thumbnail of a existent image
16
+ # key: key of the current file
17
+ # the thumbnail will be saved in my_images/my_img.png => my_images/thumb/my_img.png
18
+ def cama_uploader_generate_thumbnail(uploaded_io, key, thumb_size = nil, remove_source = false)
19
+ w = thumb_size.present? ? thumb_size.split('x')[0] : cama_uploader.thumb[:w]
20
+ h = thumb_size.present? ? thumb_size.split('x')[1] : cama_uploader.thumb[:h]
21
+ uploaded_io = File.open(uploaded_io) if uploaded_io.is_a?(String)
22
+ path_thumb = cama_resize_and_crop(uploaded_io.path, w, h)
23
+ thumb = cama_uploader.add_file(path_thumb, cama_uploader.version_path(key).sub(SVG_EXT_PATTERN, '.jpg'),
24
+ is_thumb: true, same_name: true)
25
+ FileUtils.rm_f(path_thumb) if remove_source
26
+ thumb
27
+ end
28
+
29
+ # crop and image and saved as imagename_crop.ext
30
+ # file: file path
31
+ # w: new width
32
+ # h: new height
33
+ # w_offset: left offset
34
+ # w_offset: top offset
35
+ # resize: true/false
36
+ # (true => resize the image to this dimension)
37
+ # (false => crop the image with this dimension)
38
+ # replace: Boolean (replace current image or create another file)
39
+ def cama_crop_image(file_path, w = nil, h = nil, w_offset = 0, h_offset = 0, resize = false, replace = true)
40
+ force = w.present? && h.present? && !w.include?('?') && !h.include?('?') ? '!' : ''
41
+ img = MiniMagick::Image.open(file_path)
42
+ w = clamp_to_image_dimension(w, img[:width])
43
+ h = clamp_to_image_dimension(h, img[:height])
44
+ data = { img: img, w: w, h: h, w_offset: w_offset, h_offset: h_offset, resize: resize, replace: replace }
45
+ hooks_run('before_crop_image', data)
46
+ data[:img].combine_options do |i|
47
+ i.resize("#{w.presence}x#{h.presence}#{force}") if data[:resize]
48
+ i.crop "#{w.presence}x#{h.presence}+#{w_offset}+#{h_offset}#{force}" unless data[:resize]
49
+ end
50
+
51
+ ext = File.extname(file_path)
52
+ res = data[:replace] ? file_path : file_path.gsub(ext, "_crop#{ext}")
53
+ data[:img].write res
54
+ res
55
+ end
56
+
57
+ # resize and crop a file
58
+ # SVGs are converted to JPEGs for editing
59
+ # Params:
60
+ # file: (String) File path
61
+ # w: (Integer) width
62
+ # h: (Integer) height
63
+ # settings:
64
+ # gravity: (Sym, default :north_east)
65
+ # Crop position: :north_west, :north, :north_east, :east, :south_east, :south, :south_west, :west, :center
66
+ # overwrite: (Boolean, default true) true for overwrite current image with resized resolutions,
67
+ # false: create other file called with prefix "crop_"
68
+ # output_name: (String, default prefixd name with crop_), permit to define the output name of the
69
+ # thumbnail if overwrite = true
70
+ # Return: (String) file path where saved this cropped
71
+ # sample: cama_resize_and_crop(my_file, 200, 200, {gravity: :north_east, overwrite: false})
72
+ def cama_resize_and_crop(file, w, h, settings = {})
73
+ settings = { gravity: :north_east, overwrite: true, output_name: +'' }.merge!(settings)
74
+ img = MiniMagick::Image.open(file)
75
+ if file.match?(SVG_EXT_PATTERN)
76
+ img.format 'jpg'
77
+ file.sub!(SVG_EXT_PATTERN, '.jpg')
78
+ settings[:output_name]&.sub!(SVG_EXT_PATTERN, '.jpg')
79
+ end
80
+ w = clamp_to_image_dimension(w, img[:width])
81
+ h = clamp_to_image_dimension(h, img[:height])
82
+ w_original = img[:width].to_f
83
+ h_original = img[:height].to_f
84
+ w = w.to_i if w.present?
85
+ h = h.to_i if h.present?
86
+
87
+ # check proportions
88
+ if w_original * h < h_original * w
89
+ op_resize = "#{w.to_i}x"
90
+ w_result = w
91
+ h_result = (h_original * w / w_original)
92
+ else
93
+ op_resize = "x#{h.to_i}"
94
+ w_result = (w_original * h / h_original)
95
+ h_result = h
96
+ end
97
+
98
+ w_offset, h_offset = cama_crop_offsets_by_gravity(settings[:gravity], [w_result, h_result], [w, h])
99
+ data = { img: img, w: w, h: h, w_offset: w_offset, h_offset: h_offset, op_resize: op_resize, settings: settings }
100
+ hooks_run('before_resize_crop', data)
101
+ data[:img].combine_options do |i|
102
+ i.resize(data[:op_resize])
103
+ i.gravity(settings[:gravity])
104
+ i.crop "#{data[:w].to_i}x#{data[:h].to_i}+#{data[:w_offset]}+#{data[:h_offset]}!"
105
+ end
106
+
107
+ if settings[:overwrite]
108
+ data[:img].write(file.sub(SVG_EXT_PATTERN, '.jpg'))
109
+ elsif settings[:output_name].present?
110
+ data[:img].write(file = File.join(File.dirname(file), settings[:output_name]).to_s)
111
+ else
112
+ data[:img].write(file = uploader_verify_name(
113
+ File.join(File.dirname(file), "crop_#{File.basename(file.sub(SVG_EXT_PATTERN, '.jpg'))}")
114
+ ))
115
+ end
116
+ file
117
+ end
118
+
119
+ # resize image if the format is correct
120
+ # return resized file path
121
+ def cama_resize_upload(image_path, dimension, args = {})
122
+ if cama_uploader.class.validate_file_format(image_path, 'image') && dimension.present?
123
+ dim_parts = dimension.split('x')
124
+ r = { file: image_path, w: dim_parts[0], h: dim_parts[1], w_offset: 0, h_offset: 0,
125
+ resize: !dim_parts[2] || dim_parts[2] == 'resize',
126
+ replace: true, gravity: :north_east }.merge!(args)
127
+ hooks_run('on_uploader_resize', r)
128
+ image_path = if r[:w].present? && r[:h].present?
129
+ cama_resize_and_crop(r[:file], r[:w], r[:h], { overwrite: r[:replace], gravity: r[:gravity] })
130
+ else
131
+ cama_crop_image(r[:file], r[:w], r[:h], r[:w_offset], r[:h_offset], r[:resize], r[:replace])
132
+ end
133
+ end
134
+ image_path
135
+ end
136
+
137
+ private
138
+
139
+ # helper for resize and crop method
140
+ def cama_crop_offsets_by_gravity(gravity, original_dimensions, cropped_dimensions)
141
+ original_width, original_height = original_dimensions
142
+ cropped_width, cropped_height = cropped_dimensions
143
+
144
+ vertical_offset = case gravity
145
+ when :north_west, :north, :north_east then 0
146
+ when :center, :east, :west then [((original_height - cropped_height) / 2.0).to_i, 0].max
147
+ when :south_west, :south, :south_east then (original_height - cropped_height).to_i
148
+ end
149
+
150
+ horizontal_offset = case gravity
151
+ when :north_west, :west, :south_west then 0
152
+ when :center, :north, :south then [((original_width - cropped_width) / 2.0).to_i, 0].max
153
+ when :north_east, :east, :south_east then (original_width - cropped_width).to_i
154
+ end
155
+
156
+ [horizontal_offset, vertical_offset]
157
+ end
158
+
159
+ def clamp_to_image_dimension(value, img_size)
160
+ return value unless value.present? && value.to_s.include?('?')
161
+
162
+ img_size.to_f > value.sub('?', '').to_i ? value.sub('?', '') : img_size
163
+ end
164
+ end
165
+ end
166
+ # rubocop:enable Naming/MethodParameterName