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.
- checksums.yaml +4 -4
- data/README.md +17 -4
- data/Rakefile +3 -16
- data/app/apps/plugins/attack/admin_controller.rb +9 -6
- data/app/apps/plugins/attack/attack_helper.rb +35 -25
- data/app/apps/plugins/authoring_post/authoring_post_helper.rb +23 -16
- data/app/apps/plugins/front_cache/admin_controller.rb +14 -10
- data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
- data/app/apps/plugins/front_cache/config/locales/translation.yml +0 -7
- data/app/apps/plugins/front_cache/front_cache_helper.rb +109 -37
- data/app/apps/plugins/front_cache/views/admin/settings.html.erb +0 -5
- data/app/apps/plugins/visibility_post/config/config.json +1 -0
- data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
- data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
- data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
- data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
- data/app/apps/themes/default/assets/css/style.css.scss +24 -0
- data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
- data/app/apps/themes/default/default_helper.rb +2 -1
- data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
- data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
- data/app/apps/themes/new/custom_helper.rb +5 -9
- data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
- data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
- data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
- data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
- data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
- data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +8 -6
- data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
- data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +7 -2
- data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +2 -1
- data/app/controllers/camaleon_cms/admin/categories_controller.rb +19 -4
- data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
- data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
- data/app/controllers/camaleon_cms/admin/media_controller.rb +66 -14
- data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
- data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +10 -4
- data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
- data/app/controllers/camaleon_cms/admin/posts_controller.rb +55 -22
- data/app/controllers/camaleon_cms/admin/sessions_controller.rb +237 -44
- data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +80 -13
- data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +5 -2
- data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +26 -3
- data/app/controllers/camaleon_cms/admin/settings_controller.rb +5 -3
- data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +7 -5
- data/app/controllers/camaleon_cms/admin/users_controller.rb +78 -11
- data/app/controllers/camaleon_cms/admin_controller.rb +136 -26
- data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
- data/app/controllers/camaleon_cms/frontend_controller.rb +146 -74
- data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +20 -5
- data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
- data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
- data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
- data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
- data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
- data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
- data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
- data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
- data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
- data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
- data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
- data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
- data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
- data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
- data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
- data/app/decorators/camaleon_cms/post_decorator.rb +68 -50
- data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
- data/app/decorators/camaleon_cms/site_decorator.rb +33 -25
- data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
- data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
- data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
- data/app/helpers/camaleon_cms/admin/application_helper.rb +5 -8
- data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
- data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
- data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
- data/app/helpers/camaleon_cms/admin/post_type_helper.rb +34 -17
- data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
- data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
- data/app/helpers/camaleon_cms/comment_helper.rb +19 -7
- data/app/helpers/camaleon_cms/content_helper.rb +13 -6
- data/app/helpers/camaleon_cms/email_helper.rb +7 -3
- data/app/helpers/camaleon_cms/frontend/application_helper.rb +6 -1
- data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
- data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +143 -66
- data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
- data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
- data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
- data/app/helpers/camaleon_cms/html_helper.rb +58 -37
- data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
- data/app/helpers/camaleon_cms/session_helper.rb +188 -44
- data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
- data/app/helpers/camaleon_cms/site_helper.rb +34 -13
- data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
- data/app/helpers/camaleon_cms/uploader_helper.rb +19 -473
- data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
- data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
- data/app/models/camaleon_cms/ability.rb +15 -7
- data/app/models/camaleon_cms/category.rb +6 -6
- data/app/models/camaleon_cms/custom_field.rb +15 -6
- data/app/models/camaleon_cms/custom_field_group.rb +39 -22
- data/app/models/camaleon_cms/custom_fields_relationship.rb +140 -2
- data/app/models/camaleon_cms/media.rb +8 -2
- data/app/models/camaleon_cms/meta.rb +2 -2
- data/app/models/camaleon_cms/nav_menu.rb +5 -3
- data/app/models/camaleon_cms/nav_menu_item.rb +16 -8
- data/app/models/camaleon_cms/plugin.rb +3 -4
- data/app/models/camaleon_cms/post.rb +137 -27
- data/app/models/camaleon_cms/post_comment.rb +10 -6
- data/app/models/camaleon_cms/post_default.rb +42 -12
- data/app/models/camaleon_cms/post_relationship.rb +6 -4
- data/app/models/camaleon_cms/post_tag.rb +4 -5
- data/app/models/camaleon_cms/post_type.rb +21 -13
- data/app/models/camaleon_cms/site.rb +39 -29
- data/app/models/camaleon_cms/term_relationship.rb +10 -7
- data/app/models/camaleon_cms/term_taxonomy.rb +58 -4
- data/app/models/camaleon_cms/theme.rb +2 -4
- data/app/models/camaleon_cms/user.rb +17 -5
- data/app/models/camaleon_cms/user_role.rb +81 -6
- data/app/models/camaleon_cms/widget/assigned.rb +11 -6
- data/app/models/camaleon_cms/widget/main.rb +12 -8
- data/app/models/camaleon_cms/widget/sidebar.rb +3 -8
- data/app/models/camaleon_record.rb +89 -5
- data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +36 -42
- data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
- data/app/models/concerns/camaleon_cms/content_shortcode_gate.rb +73 -0
- data/app/models/concerns/camaleon_cms/custom_fields_read.rb +111 -54
- data/app/models/concerns/camaleon_cms/metas.rb +31 -6
- data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
- data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
- data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
- data/app/models/current_request.rb +8 -1
- data/app/uploaders/camaleon_cms_aws_uploader.rb +31 -10
- data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
- data/app/uploaders/camaleon_cms_uploader.rb +23 -10
- data/app/validators/camaleon_cms/post_uniq_validator.rb +5 -4
- data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
- data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
- data/app/views/camaleon_cms/admin/categories/index.html.erb +2 -2
- data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
- data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
- data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
- data/app/views/camaleon_cms/admin/post_tags/index.html.erb +2 -2
- data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +1 -1
- data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
- data/app/views/camaleon_cms/admin/posts/index.html.erb +5 -5
- data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
- data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
- data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +19 -4
- data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +4 -3
- data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
- data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
- data/app/views/camaleon_cms/admin/user_roles/form.html.erb +24 -3
- data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
- data/app/views/camaleon_cms/admin/users/index.html.erb +1 -1
- data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
- data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
- data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
- data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
- data/config/initializers/action_view.rb +42 -5
- data/config/initializers/active_record_extension.rb +13 -27
- data/config/initializers/assets.rb +52 -23
- data/config/initializers/custom_initializers.rb +1 -1
- data/config/locales/camaleon_cms/admin/ar.yml +1 -0
- data/config/locales/camaleon_cms/admin/de.yml +1 -0
- data/config/locales/camaleon_cms/admin/en.yml +36 -0
- data/config/locales/camaleon_cms/admin/es.yml +1 -0
- data/config/locales/camaleon_cms/admin/fr.yml +1 -0
- data/config/locales/camaleon_cms/admin/it.yml +1 -0
- data/config/locales/camaleon_cms/admin/nl.yml +1 -0
- data/config/locales/camaleon_cms/admin/pt-BR.yml +1 -0
- data/config/locales/camaleon_cms/admin/pt.yml +1 -0
- data/config/locales/camaleon_cms/admin/ru.yml +1 -0
- data/config/locales/camaleon_cms/admin/uk.yml +1 -0
- data/config/locales/camaleon_cms/admin/zh-CH.yml +1 -0
- data/config/routes/admin.rb +33 -15
- data/config/routes/frontend.rb +21 -15
- data/lib/camaleon_cms/assets_precompile.rb +86 -0
- data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
- data/lib/camaleon_cms/content_security.rb +158 -0
- data/lib/camaleon_cms/engine.rb +45 -21
- data/lib/camaleon_cms/media_security_headers.rb +27 -0
- data/lib/camaleon_cms/setup_token.rb +69 -0
- data/lib/camaleon_cms/shortcode_registry.rb +102 -0
- data/lib/camaleon_cms/svg_content_checker.rb +123 -0
- data/lib/camaleon_cms/task_reporter.rb +14 -0
- data/lib/camaleon_cms/unsafe_markup.rb +207 -0
- data/lib/camaleon_cms/uploader_content_security.rb +248 -0
- data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
- data/lib/camaleon_cms/uploader_path_security.rb +146 -0
- data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
- data/lib/camaleon_cms/uploader_support.rb +86 -0
- data/lib/camaleon_cms/version.rb +1 -1
- data/lib/camaleon_cms.rb +23 -0
- data/lib/ext/hash.rb +28 -2
- data/lib/ext/string.rb +10 -5
- data/lib/ext/translator.rb +10 -2
- data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
- data/lib/generators/camaleon_cms/install_generator.rb +7 -4
- data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
- data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
- data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
- data/lib/plugin_routes.rb +412 -368
- data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
- data/lib/tasks/cross_site_field_groups.rake +74 -0
- data/lib/tasks/custom_fields_roles.rake +15 -13
- data/lib/tasks/orphaned_comments.rake +42 -0
- data/lib/tasks/private_upload_acls.rake +31 -0
- data/lib/tasks/site_custom_field_groups.rake +36 -0
- data/lib/tasks/unsafe_stored_content.rake +42 -0
- data/lib/tasks/unsafe_stored_uploads.rake +70 -0
- data/lib/tasks/user_field_groups.rake +27 -0
- 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 (`<img ...>`) 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
|
+
# (`<script>`) 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
|