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,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'addressable/uri'
|
|
4
|
+
|
|
5
|
+
module CamaleonCms
|
|
6
|
+
# Shared helpers that keep file-upload path handling in a single place so the
|
|
7
|
+
# canonicalization guard and same-site URL detection cannot drift between
|
|
8
|
+
# RuntimeUploaderConcern and UploaderHelper (which both include this module).
|
|
9
|
+
module UploaderPathSecurity
|
|
10
|
+
# Canonicalizes a string path and verifies it stays within the allowed upload
|
|
11
|
+
# roots (the Rails public dir or the system tmp dir). Returns the expanded path
|
|
12
|
+
# when valid, or nil when the path escapes the roots or is otherwise hostile
|
|
13
|
+
# (null bytes, nil).
|
|
14
|
+
#
|
|
15
|
+
# `extra_roots` widens the set for this call only, so trusted application code
|
|
16
|
+
# (plugins, jobs, imports) can stage files elsewhere. It MUST come from
|
|
17
|
+
# application code or operator configuration — never from a request parameter,
|
|
18
|
+
# since Admin::MediaController#crop feeds params[:cp_img_path] into this check and
|
|
19
|
+
# the default roots are what stop it reading arbitrary files.
|
|
20
|
+
def cama_canonical_upload_path(path, extra_roots: [])
|
|
21
|
+
expanded = File.expand_path(path)
|
|
22
|
+
roots = cama_allowed_upload_roots(extra_roots)
|
|
23
|
+
return expanded if roots.any? { |r| expanded == r || expanded.start_with?(r + File::SEPARATOR) }
|
|
24
|
+
|
|
25
|
+
nil
|
|
26
|
+
rescue ArgumentError, TypeError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The default roots plus any caller-supplied ones, and the private-media directory
|
|
31
|
+
# while the uploader is in private mode (so private files can be cropped without
|
|
32
|
+
# widening the roots for anything else).
|
|
33
|
+
def cama_allowed_upload_roots(extra_roots = [])
|
|
34
|
+
roots = [Rails.public_path.to_s, Dir.tmpdir]
|
|
35
|
+
roots << cama_private_upload_root if cama_private_upload_mode?
|
|
36
|
+
roots.concat(Array.wrap(extra_roots).compact_blank.map { |r| File.expand_path(r.to_s) })
|
|
37
|
+
roots.compact_blank
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cama_private_upload_root
|
|
41
|
+
Rails.root.join(CamaleonCmsUploader::PRIVATE_DIRECTORY).to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cama_private_upload_mode?
|
|
45
|
+
respond_to?(:cama_uploader, true) && cama_uploader.try(:is_private_uploader?).present?
|
|
46
|
+
rescue StandardError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reads a caller-supplied root list off an options hash, accepting either key
|
|
51
|
+
# form. Only application code populates this; see cama_canonical_upload_path.
|
|
52
|
+
def cama_extra_upload_roots(options)
|
|
53
|
+
opts = options.try(:to_h) || {}
|
|
54
|
+
Array.wrap(opts[:allowed_roots] || opts['allowed_roots'])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Removes a staged upload file, but only after confirming it canonicalizes
|
|
58
|
+
# inside the given staging root. A bug in the calling code therefore cannot
|
|
59
|
+
# turn into a deletion elsewhere on the filesystem. Returns true when a file
|
|
60
|
+
# was removed.
|
|
61
|
+
def cama_purge_staged_file(path, root)
|
|
62
|
+
return false if path.blank? || root.blank?
|
|
63
|
+
return false unless path_within?(path, root)
|
|
64
|
+
|
|
65
|
+
FileUtils.rm_f(path)
|
|
66
|
+
true
|
|
67
|
+
rescue ArgumentError, TypeError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Upper bound on the decoded byte size of a base64 payload, computed without
|
|
72
|
+
# decoding it, so an oversized upload can be rejected before it is allocated.
|
|
73
|
+
# Overestimates by at most two bytes (padding), erring toward rejecting early.
|
|
74
|
+
def cama_base64_decoded_size(payload)
|
|
75
|
+
payload.to_s.bytesize * 3 / 4
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Passes an upload error through untouched, first removing the staging file when
|
|
79
|
+
# this upload owns it (remove_source), so a rejected upload leaves nothing behind
|
|
80
|
+
# in the web-served public/tmp directory. Shared so the cleanup rule cannot drift
|
|
81
|
+
# between RuntimeUploaderConcern and UploaderHelper.
|
|
82
|
+
def cama_upload_failure(error, uploaded_io, settings)
|
|
83
|
+
# The first (malicious-content) rejection in upload_file runs before settings are
|
|
84
|
+
# deep-symbolized, so honor a string-keyed remove_source too — otherwise a rejected upload
|
|
85
|
+
# owning its staging file leaks it in the web-served public/tmp directory.
|
|
86
|
+
remove_source = settings[:remove_source] || settings['remove_source']
|
|
87
|
+
return error unless remove_source
|
|
88
|
+
|
|
89
|
+
cama_purge_staged_file(uploaded_io.try(:path), File.join(Rails.public_path, 'tmp').to_s)
|
|
90
|
+
error
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True when the canonicalized path stays strictly inside the given root
|
|
94
|
+
# directory. Used as a defense-in-depth check around write sinks.
|
|
95
|
+
def path_within?(path, root)
|
|
96
|
+
File.expand_path(path).start_with?("#{File.expand_path(root)}#{File::SEPARATOR}")
|
|
97
|
+
rescue ArgumentError, TypeError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def same_site_url?(url, site)
|
|
102
|
+
uri = Addressable::URI.parse(url)
|
|
103
|
+
site_uri = Addressable::URI.parse(site.the_url(locale: nil))
|
|
104
|
+
same_host?(uri.host, site_uri.host) && uri.inferred_port == site_uri.inferred_port
|
|
105
|
+
rescue Addressable::URI::InvalidURIError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Case-insensitive host comparison that also ignores a single trailing dot, so
|
|
110
|
+
# a fully-qualified form ("site.com.") still matches the site host ("site.com")
|
|
111
|
+
# instead of being classified as a remote host — which would trigger a needless
|
|
112
|
+
# (and re-validated) outbound fetch to the site itself.
|
|
113
|
+
def same_host?(host_a, host_b)
|
|
114
|
+
return false if host_a.blank? || host_b.blank?
|
|
115
|
+
|
|
116
|
+
host_a.downcase.chomp('.') == host_b.downcase.chomp('.')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def site_url_path(url, site)
|
|
120
|
+
uri = Addressable::URI.parse(url)
|
|
121
|
+
path = uri.path.to_s
|
|
122
|
+
# Strip the site's mount subpath (relative_url_root), if any, so that a
|
|
123
|
+
# same-site URL under e.g. "http://host/blog/" maps to public/... and not
|
|
124
|
+
# public/blog/...
|
|
125
|
+
base = Addressable::URI.parse(site.the_url(locale: nil)).path.to_s.chomp('/')
|
|
126
|
+
path = path.sub(%r{\A#{Regexp.escape(base)}(?=/|$)}, '') if base.present?
|
|
127
|
+
strip_locale_prefix(path, site)
|
|
128
|
+
rescue Addressable::URI::InvalidURIError
|
|
129
|
+
url
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Removes a leading locale segment (e.g. "/es") on multi-language sites. To
|
|
133
|
+
# avoid mis-stripping a real first directory that merely shares a language
|
|
134
|
+
# code's name, only strip when the stripped path points at an existing file
|
|
135
|
+
# under the public dir; otherwise keep the path as-is.
|
|
136
|
+
def strip_locale_prefix(path, site)
|
|
137
|
+
langs = site.get_languages
|
|
138
|
+
return path unless langs.size > 1
|
|
139
|
+
|
|
140
|
+
stripped = path.sub(%r{\A/(?:#{Regexp.union(langs.map(&:to_s))})(?=/|$)}, '')
|
|
141
|
+
return path if stripped == path
|
|
142
|
+
|
|
143
|
+
File.exist?(File.expand_path(File.join(Rails.public_path, stripped))) ? stripped : path
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
|
|
6
|
+
module CamaleonCms
|
|
7
|
+
# Staging and persistence pipeline shared by the two uploader entry points:
|
|
8
|
+
# CamaleonCms::RuntimeUploaderConcern (controllers) and CamaleonCms::UploaderHelper
|
|
9
|
+
# (views, ActiveJobs, standalone objects). Both include this module, so a fix to
|
|
10
|
+
# upload staging or persistence cannot land in one entry point and not the other.
|
|
11
|
+
#
|
|
12
|
+
# Nothing here may read request state such as `params`: UploaderHelper is documented
|
|
13
|
+
# in config/initializers/custom_initializers.rb as includable from an ActiveJob, where
|
|
14
|
+
# no request exists. Context differences go through the message seam at the bottom.
|
|
15
|
+
module UploaderPipeline
|
|
16
|
+
# upload a file into server
|
|
17
|
+
# settings:
|
|
18
|
+
# folder: Directory where the file will be saved (default: "")
|
|
19
|
+
# sample: temporal => will save in /rails_path/public/temporal
|
|
20
|
+
# generate_thumb: true, # generate thumb image if this is image format (default true)
|
|
21
|
+
# maximum: maximum bytes permitted to upload (default: 1000MG)
|
|
22
|
+
# dimension: dimension for the image (sample: 30x30 | x30 | 30x | 300x300?)
|
|
23
|
+
# formats: extensions permitted, sample: jpg,png,... or generic: images | videos | audios | documents (default *)
|
|
24
|
+
# remove_source: Boolean (delete source file after saved if this is true, default false)
|
|
25
|
+
# same_name: Boolean (save the file with the same name if defined true, else search for a non used name)
|
|
26
|
+
# versions: (String) Create additional multiple versions of the image uploaded,
|
|
27
|
+
# sample: '300x300,505x350' ==> Will create two extra images with these dimensions
|
|
28
|
+
# sample "test.png", versions: '200x200,450x450' will generate: thumb/test-png_200x200.png, test-png_450x450.png
|
|
29
|
+
# thumb_size: String (redefine the dimensions of the thumbnail, sample: '100x100' ==> only for images)
|
|
30
|
+
# temporal_time: if great than 0 seconds, then this file will expire (removed) in that time (default: 0)
|
|
31
|
+
# To manage jobs, please check https://edgeguides.rubyonrails.org/active_job_basics.html
|
|
32
|
+
# Note: if you are using temporal_time, you will need to copy the file to another directory later
|
|
33
|
+
# sample: upload_file(params[:my_file], {formats: "images", folder: "temporal"})
|
|
34
|
+
# sample: upload_file(params[:my_file], {formats: "jpg,png,gif,mp3,mp4",
|
|
35
|
+
# temporal_time: 10.minutes, maximum: 10.megabytes})
|
|
36
|
+
def upload_file(uploaded_io, settings = {})
|
|
37
|
+
cached_name = uploaded_io.is_a?(ActionDispatch::Http::UploadedFile) ? uploaded_io.original_filename : nil
|
|
38
|
+
return { error: 'File is empty', file: nil, size: nil } if uploaded_io.blank?
|
|
39
|
+
|
|
40
|
+
if uploaded_io.is_a?(String) && uploaded_io.match(%r{^https?://}).present? # download url file
|
|
41
|
+
tmp = cama_tmp_upload(uploaded_io)
|
|
42
|
+
return tmp if tmp[:error].present?
|
|
43
|
+
|
|
44
|
+
settings[:remove_source] = true
|
|
45
|
+
uploaded_io = tmp[:file_path]
|
|
46
|
+
end
|
|
47
|
+
if uploaded_io.is_a?(String)
|
|
48
|
+
expanded = cama_canonical_upload_path(uploaded_io, extra_roots: cama_extra_upload_roots(settings))
|
|
49
|
+
return { error: 'Invalid file path' } unless expanded
|
|
50
|
+
|
|
51
|
+
uploaded_io = expanded
|
|
52
|
+
end
|
|
53
|
+
uploaded_io = File.open(uploaded_io) if uploaded_io.is_a?(String)
|
|
54
|
+
if settings[:dimension].present?
|
|
55
|
+
uploaded_io = File.open(cama_resize_upload(uploaded_io.path, settings[:dimension]))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Permission first: a trusted upload never reads the file in order to scan it.
|
|
59
|
+
if !cama_trusted_for_unfiltered_upload? && file_content_unsafe?(uploaded_io)
|
|
60
|
+
return cama_upload_failure({ error: 'Potentially malicious content found!' }, uploaded_io, settings)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
settings = settings.to_h.deep_symbolize_keys
|
|
64
|
+
settings[:uploaded_io] = uploaded_io
|
|
65
|
+
settings = {
|
|
66
|
+
folder: '',
|
|
67
|
+
maximum: current_site.get_option('filesystem_max_size', 100).to_f.megabytes,
|
|
68
|
+
formats: '*',
|
|
69
|
+
generate_thumb: true,
|
|
70
|
+
temporal_time: 0,
|
|
71
|
+
filename: begin
|
|
72
|
+
cached_name || uploaded_io.original_filename
|
|
73
|
+
rescue StandardError
|
|
74
|
+
uploaded_io.path.split('/').last
|
|
75
|
+
end.cama_fix_filename,
|
|
76
|
+
file_size: File.size(uploaded_io.to_io),
|
|
77
|
+
remove_source: false,
|
|
78
|
+
same_name: false,
|
|
79
|
+
versions: '',
|
|
80
|
+
thumb_size: nil
|
|
81
|
+
}.merge!(settings)
|
|
82
|
+
settings[:formats] = '*' if settings[:formats].nil?
|
|
83
|
+
settings[:folder] = '' if settings[:folder].nil? # e.g. crop_url passes no folder
|
|
84
|
+
io_before_hook = settings[:uploaded_io]
|
|
85
|
+
hooks_run('before_upload', settings)
|
|
86
|
+
|
|
87
|
+
# A before_upload handler may rebind settings[:uploaded_io] to bytes the top-of-method
|
|
88
|
+
# scan never saw (e.g. an image optimizer rewriting an SVG). For an untrusted uploader,
|
|
89
|
+
# re-scan the substituted IO so a handler cannot launder a blocked payload past the scan.
|
|
90
|
+
# Keyed on object identity: an unchanged IO was already scanned, and a permission-holder
|
|
91
|
+
# is exempt exactly as above.
|
|
92
|
+
if !settings[:uploaded_io].equal?(io_before_hook) && !cama_trusted_for_unfiltered_upload? &&
|
|
93
|
+
file_content_unsafe?(settings[:uploaded_io])
|
|
94
|
+
return cama_upload_failure({ error: 'Potentially malicious content found!' }, settings[:uploaded_io], settings)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# guard against path traversal
|
|
98
|
+
unless cama_uploader.valid_folder_path?(settings[:folder])
|
|
99
|
+
return cama_upload_failure({ error: 'Invalid file path' }, uploaded_io, settings)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# formats validations
|
|
103
|
+
err = validate_file_format_or_error(uploaded_io.path, settings[:formats])
|
|
104
|
+
return cama_upload_failure(err, uploaded_io, settings) if err
|
|
105
|
+
|
|
106
|
+
# file size validations
|
|
107
|
+
err = cama_size_limit_error(settings[:file_size], settings[:maximum])
|
|
108
|
+
return cama_upload_failure(err, uploaded_io, settings) if err
|
|
109
|
+
|
|
110
|
+
# save file
|
|
111
|
+
key = File.join(settings[:folder], settings[:filename]).to_s.cama_fix_slash
|
|
112
|
+
res = cama_uploader.add_file(settings[:uploaded_io], key, { same_name: settings[:same_name] })
|
|
113
|
+
|
|
114
|
+
# generate image versions
|
|
115
|
+
if res['file_type'] == 'image'
|
|
116
|
+
settings[:versions].to_s.delete(' ').split(',').each do |v|
|
|
117
|
+
version_path = cama_resize_upload(settings[:uploaded_io].path, v, { replace: false })
|
|
118
|
+
cama_uploader.add_file(version_path, cama_uploader.version_path(res['key'], v), is_thumb: true,
|
|
119
|
+
same_name: true)
|
|
120
|
+
FileUtils.rm_f(version_path)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# generate thumb
|
|
125
|
+
if settings[:generate_thumb] && res['thumb'].present?
|
|
126
|
+
cama_uploader_generate_thumbnail(uploaded_io.path, res['key'], settings[:thumb_size],
|
|
127
|
+
settings[:remove_source])
|
|
128
|
+
end
|
|
129
|
+
FileUtils.rm_f(uploaded_io.path) if settings[:remove_source] && File.exist?(uploaded_io.path)
|
|
130
|
+
|
|
131
|
+
hooks_run('after_upload', settings)
|
|
132
|
+
|
|
133
|
+
# temporal file upload (always put as local for temporal files)
|
|
134
|
+
CamaleonCmsUploader.delete_block.call(settings, cama_uploader, key) if settings[:temporal_time] > 0
|
|
135
|
+
|
|
136
|
+
res
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# upload tmp file
|
|
140
|
+
# support for url and local path
|
|
141
|
+
# sample:
|
|
142
|
+
# cama_tmp_upload('https://camaleon.website/media/132/logo2.png') ==> /var/rails/my_project/public/tmp/1/logo2.png
|
|
143
|
+
# cama_tmp_upload('/var/www/media/132/logo 2.png') ==> /var/rails/my_project/public/tmp/1/logo-2.png
|
|
144
|
+
# accept args:
|
|
145
|
+
# name: to indicate the name to use,
|
|
146
|
+
# sample: cama_tmp_upload('/var/www/media/132/logo 2.png', {name: 'owen.png', formats: 'images'})
|
|
147
|
+
# formats: extensions permitted, sample: jpg,png,... or generic: images | videos | audios | documents (default *)
|
|
148
|
+
# dimension: 20x30
|
|
149
|
+
# return: {file_path, error}
|
|
150
|
+
def cama_tmp_upload(uploaded_io, args = {})
|
|
151
|
+
tmp_path = args[:path] || File.join(Rails.public_path, 'tmp', current_site.id.to_s).to_s
|
|
152
|
+
FileUtils.mkdir_p(tmp_path)
|
|
153
|
+
# Default to the site limit so the size guard below actually applies: callers
|
|
154
|
+
# such as crop/crop_url pass no :maximum, which left it dead code.
|
|
155
|
+
args[:maximum] ||= current_site.get_option('filesystem_max_size', 100).to_f.megabytes
|
|
156
|
+
saved = false
|
|
157
|
+
downloaded_tmp_file = nil
|
|
158
|
+
staged_path = nil
|
|
159
|
+
if uploaded_io.is_a?(String) && uploaded_io.start_with?('data:') # create tmp file using base64 format
|
|
160
|
+
path, err = cama_stage_data_uri(uploaded_io, args, tmp_path)
|
|
161
|
+
return err if err
|
|
162
|
+
|
|
163
|
+
staged_path = path
|
|
164
|
+
_tmp_name = File.basename(args[:name].to_s)
|
|
165
|
+
uploaded_io = File.open(path)
|
|
166
|
+
saved = true
|
|
167
|
+
elsif uploaded_io.is_a?(String) && uploaded_io.start_with?('http://', 'https://')
|
|
168
|
+
err = validate_file_format_or_error(uploaded_io, args[:formats])
|
|
169
|
+
return err if err
|
|
170
|
+
|
|
171
|
+
if same_site_url?(uploaded_io, current_site)
|
|
172
|
+
uploaded_io = File.join(Rails.public_path, site_url_path(uploaded_io, current_site)).to_s
|
|
173
|
+
else
|
|
174
|
+
remote_file = cama_download_remote_file(uploaded_io)
|
|
175
|
+
return remote_file if remote_file[:error].present?
|
|
176
|
+
|
|
177
|
+
downloaded_tmp_file = remote_file[:file]
|
|
178
|
+
uploaded_io = downloaded_tmp_file
|
|
179
|
+
end
|
|
180
|
+
_tmp_name = if uploaded_io.is_a?(String)
|
|
181
|
+
uploaded_io.split('/').last.split('?').first
|
|
182
|
+
else
|
|
183
|
+
uploaded_io.path.split('/').last
|
|
184
|
+
end
|
|
185
|
+
args[:name] = args[:name] || _tmp_name
|
|
186
|
+
end
|
|
187
|
+
if uploaded_io.is_a?(String)
|
|
188
|
+
expanded = cama_canonical_upload_path(uploaded_io, extra_roots: cama_extra_upload_roots(args))
|
|
189
|
+
return { error: 'Invalid file path' } unless expanded
|
|
190
|
+
|
|
191
|
+
uploaded_io = expanded
|
|
192
|
+
end
|
|
193
|
+
uploaded_io = File.open(uploaded_io) if uploaded_io.is_a?(String)
|
|
194
|
+
err = validate_file_format_or_error(_tmp_name || uploaded_io.path, args[:formats])
|
|
195
|
+
return err if err
|
|
196
|
+
|
|
197
|
+
actual_size = begin
|
|
198
|
+
uploaded_io.size
|
|
199
|
+
rescue StandardError
|
|
200
|
+
File.size(uploaded_io)
|
|
201
|
+
end
|
|
202
|
+
err = cama_size_limit_error(actual_size, args[:maximum])
|
|
203
|
+
return err if err
|
|
204
|
+
|
|
205
|
+
name = args[:name] || uploaded_io&.original_filename || uploaded_io.path.split('/').last
|
|
206
|
+
name = "#{File.basename(name, File.extname(name)).parameterize}#{File.extname(name)}"
|
|
207
|
+
path ||= uploader_verify_name(File.join(tmp_path, name))
|
|
208
|
+
unless saved
|
|
209
|
+
# Same rule as the data: branch above -- read, scan, and only then write, so no
|
|
210
|
+
# source can land unscanned in public/tmp. The scan is keyed on `name`, the output
|
|
211
|
+
# filename, because that is the extension the web server will serve the bytes under;
|
|
212
|
+
# only the uploader's permission decides whether it runs.
|
|
213
|
+
content = uploaded_io.read
|
|
214
|
+
if !cama_trusted_for_unfiltered_upload? && content_unsafe?(content, filename: name)
|
|
215
|
+
return { error: 'Potentially malicious content found!' }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
File.open(path, 'wb') { |f| f.write(content) }
|
|
219
|
+
staged_path = path
|
|
220
|
+
end
|
|
221
|
+
path = cama_resize_upload(path, args[:dimension]) if args[:dimension].present?
|
|
222
|
+
{ file_path: path, error: nil }
|
|
223
|
+
rescue StandardError
|
|
224
|
+
# A raised error leaves no half-written file behind in the served staging dir.
|
|
225
|
+
cama_purge_staged_file(staged_path, tmp_path)
|
|
226
|
+
raise
|
|
227
|
+
ensure
|
|
228
|
+
downloaded_tmp_file&.close!
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Message seam. Rendering user-facing upload errors differs by execution context,
|
|
232
|
+
# so the pipeline never calls a translator directly.
|
|
233
|
+
#
|
|
234
|
+
# `ct` runs the `on_translation` hook that lets plugins override the text, which a
|
|
235
|
+
# shared I18n.t call would silently drop. CamaleonCms::UploaderHelper overrides all
|
|
236
|
+
# three to route through `ct` / `cama_t` / `number_to_human_size` (it includes
|
|
237
|
+
# CamaleonHelper itself). CamaleonCms::RuntimeUploaderConcern overrides only
|
|
238
|
+
# cama_uploader_ct, routing through `ct` when its host has one — true for
|
|
239
|
+
# CamaleonCms::CamaleonController since #1223 restored CamaleonHelper there. The
|
|
240
|
+
# defaults below are what remains: `t`/`human_size` on the concern path, and all
|
|
241
|
+
# three for concern hosts without `ct` (ActiveJobs, standalone objects).
|
|
242
|
+
def cama_uploader_ct(key, args = {})
|
|
243
|
+
I18n.t("camaleon_cms.common.#{key}", **args)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def cama_uploader_t(key, args = {})
|
|
247
|
+
I18n.t(key, **args)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def cama_uploader_human_size(bytes)
|
|
251
|
+
ActiveSupport::NumberHelper.number_to_human_size(bytes)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
# Download remote files with SSRF guardrails: validate host/IP and reject redirects.
|
|
257
|
+
# NOTE: UserUrlValidator.validate is intentionally called here even though the
|
|
258
|
+
# crop_url controller path may have already validated the URL — this ensures
|
|
259
|
+
# every caller of cama_tmp_upload is protected (defense-in-depth).
|
|
260
|
+
def cama_download_remote_file(url)
|
|
261
|
+
validator = UserUrlValidator.new
|
|
262
|
+
validation_result = validator.validate(url, reject_path_traversal: true)
|
|
263
|
+
return { error: validation_result.join(', ') } if validation_result.is_a?(Array)
|
|
264
|
+
|
|
265
|
+
uri = URI.parse(url)
|
|
266
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
267
|
+
http.use_ssl = uri.scheme == 'https'
|
|
268
|
+
# Pin the socket to the exact IP the validator vetted so a hostname whose DNS
|
|
269
|
+
# answer changes between the SSRF check and the fetch (DNS rebinding) cannot
|
|
270
|
+
# redirect the request to an internal address; SNI/cert checks keep the host.
|
|
271
|
+
http.ipaddr = validator.resolved_ip if validator.resolved_ip
|
|
272
|
+
http.open_timeout = 10
|
|
273
|
+
http.read_timeout = 10
|
|
274
|
+
response = http.start do |conn|
|
|
275
|
+
conn.request(Net::HTTP::Get.new(uri.request_uri.presence || '/'))
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
return { error: 'Redirects are not allowed for remote uploads.' } if response.is_a?(Net::HTTPRedirection)
|
|
279
|
+
|
|
280
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
281
|
+
return { error: "Unable to download remote file (HTTP #{response.code})." }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Enforce the site's maximum upload size to prevent memory exhaustion from oversized responses.
|
|
285
|
+
max_bytes = current_site.get_option('filesystem_max_size', 100).to_f.megabytes
|
|
286
|
+
body = response.body
|
|
287
|
+
if body.bytesize > max_bytes
|
|
288
|
+
return { error: "Remote file too large (max #{ActiveSupport::NumberHelper.number_to_human_size(max_bytes)})." }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
ext = File.extname(uri.path.to_s)
|
|
292
|
+
tempfile = Tempfile.new(['cama-upload-url', ext], binmode: true)
|
|
293
|
+
tempfile.write(body)
|
|
294
|
+
tempfile.rewind
|
|
295
|
+
{ file: tempfile, error: nil }
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
{ error: "Unable to download remote file: #{ERB::Util.html_escape(e.message)}" }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def validate_file_format_or_error(file, formats)
|
|
301
|
+
return if cama_uploader.class.validate_file_format(file, formats)
|
|
302
|
+
|
|
303
|
+
{ error: "#{cama_uploader_ct('file_format_error')} (#{formats})" }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Stages a base64 data: payload into tmp_path, bounding its size and scanning its
|
|
307
|
+
# content BEFORE the write, so nothing oversized or hostile ever reaches a path
|
|
308
|
+
# the web server would hand out. Returns [path, error]; exactly one is non-nil.
|
|
309
|
+
def cama_stage_data_uri(uploaded_io, args, tmp_path)
|
|
310
|
+
return [nil, { error: cama_uploader_t('camaleon_cms.admin.media.name_required').to_s }] if args[:name].blank?
|
|
311
|
+
|
|
312
|
+
# Strip any directory components so a hostile name (e.g. "../../etc/x")
|
|
313
|
+
# cannot escape tmp_path when the base64 payload is written below.
|
|
314
|
+
tmp_name = File.basename(args[:name].to_s)
|
|
315
|
+
err = validate_file_format_or_error(tmp_name, args[:formats])
|
|
316
|
+
return [nil, err] if err
|
|
317
|
+
|
|
318
|
+
payload = uploaded_io.split(';base64,').last
|
|
319
|
+
# Bound the payload before decoding, so an oversized upload is never allocated
|
|
320
|
+
# in full nor written into the served staging directory.
|
|
321
|
+
err = cama_size_limit_error(cama_base64_decoded_size(payload), args[:maximum])
|
|
322
|
+
return [nil, err] if err
|
|
323
|
+
|
|
324
|
+
# The decoded bytes are already in memory here, so scanning costs no extra copy.
|
|
325
|
+
decoded = Base64.decode64(payload)
|
|
326
|
+
if !cama_trusted_for_unfiltered_upload? && content_unsafe?(decoded, filename: tmp_name)
|
|
327
|
+
return [nil, { error: 'Potentially malicious content found!' }]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
path = uploader_verify_name(File.join(tmp_path, tmp_name))
|
|
331
|
+
return [nil, { error: 'Invalid file path' }] unless path_within?(path, tmp_path)
|
|
332
|
+
|
|
333
|
+
File.open(path, 'wb') { |f| f.write(decoded) }
|
|
334
|
+
[path, nil]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Returns an error hash when size exceeds maximum, nil otherwise.
|
|
338
|
+
# A non-positive maximum means "no limit" — a site whose stored filesystem_max_size is blank
|
|
339
|
+
# or 0 (also reached by plugin callers passing that option as :maximum) would otherwise fail
|
|
340
|
+
# every upload with "File size exceeded (0 Bytes)". The size comparison coerces for the same
|
|
341
|
+
# reason: a caller-supplied :maximum can be a numeric String, and a bare `String >= Integer`
|
|
342
|
+
# raises instead of enforcing the limit.
|
|
343
|
+
def cama_size_limit_error(size, maximum)
|
|
344
|
+
return if maximum.blank? || maximum.to_f <= 0 || maximum.to_f >= size
|
|
345
|
+
|
|
346
|
+
max_size = cama_uploader_human_size(maximum)
|
|
347
|
+
{ error: "#{cama_uploader_ct('file_size_exceeded', default: 'File size exceeded')} (#{max_size})" }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamaleonCms
|
|
4
|
+
# Uploader backend resolution plus the filename and URL helpers that the staging
|
|
5
|
+
# and image-processing pipelines build on. Shared by the two uploader entry points,
|
|
6
|
+
# CamaleonCms::RuntimeUploaderConcern and CamaleonCms::UploaderHelper.
|
|
7
|
+
module UploaderSupport
|
|
8
|
+
# return the current uploader
|
|
9
|
+
def cama_uploader
|
|
10
|
+
@cama_uploader ||= lambda {
|
|
11
|
+
thumb = current_site.get_option('filesystem_thumb_size', '100x100').split('x')
|
|
12
|
+
args = {
|
|
13
|
+
server: current_site.get_option('filesystem_type', 'local').downcase,
|
|
14
|
+
thumb: { w: thumb[0], h: thumb[1] },
|
|
15
|
+
aws_settings: {
|
|
16
|
+
region: current_site.get_option('filesystem_region', 'us-west-2'),
|
|
17
|
+
access_key: current_site.get_option('filesystem_s3_access_key'),
|
|
18
|
+
secret_key: current_site.get_option('filesystem_s3_secret_key'),
|
|
19
|
+
bucket: current_site.get_option('filesystem_s3_bucket_name'),
|
|
20
|
+
cloud_front: current_site.get_option('filesystem_s3_cloudfront'),
|
|
21
|
+
# permit to add your custom attributes for
|
|
22
|
+
# file_upload https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Object.html#upload_file-instance_method
|
|
23
|
+
aws_file_upload_settings: ->(settings) { settings },
|
|
24
|
+
# permit to read custom attributes from aws file and add to file parsed object
|
|
25
|
+
aws_file_read_settings: ->(data, _s3_file) { data }
|
|
26
|
+
},
|
|
27
|
+
custom_uploader: nil # possibility to use custom file uploader
|
|
28
|
+
}
|
|
29
|
+
hooks_run('on_uploader', args)
|
|
30
|
+
return args[:custom_uploader] if args[:custom_uploader].present?
|
|
31
|
+
|
|
32
|
+
base_args = { current_site: current_site, thumb: args[:thumb] }
|
|
33
|
+
case args[:server]
|
|
34
|
+
when 's3', 'aws'
|
|
35
|
+
CamaleonCmsAwsUploader.new(base_args.merge(aws_settings: args[:aws_settings]), self)
|
|
36
|
+
else
|
|
37
|
+
CamaleonCmsLocalUploader.new(base_args, self)
|
|
38
|
+
end
|
|
39
|
+
}.call
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# helper to find an available filename for file_path in that directory
|
|
43
|
+
# sample: uploader_verify_name("/var/www/my_image.jpg")
|
|
44
|
+
# return "/var/www/my_image_1.jpg" => if "/var/www/my_image.jpg" exist
|
|
45
|
+
# return "/var/www/my_image.jpg" => if "/var/www/my_image.jpg" doesn't exist
|
|
46
|
+
def uploader_verify_name(file_path)
|
|
47
|
+
dir = File.dirname(file_path)
|
|
48
|
+
filename = File.basename(file_path).to_s.cama_fix_filename
|
|
49
|
+
files = Dir.entries(dir)
|
|
50
|
+
if files.include?(filename)
|
|
51
|
+
i = 1
|
|
52
|
+
_filename = filename
|
|
53
|
+
while files.include?(_filename)
|
|
54
|
+
_filename = "#{File.basename(filename, File.extname(filename))}_#{i}#{File.extname(filename)}"
|
|
55
|
+
i += 1
|
|
56
|
+
end
|
|
57
|
+
filename = _filename
|
|
58
|
+
end
|
|
59
|
+
"#{File.dirname(file_path)}/#{filename}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# convert downloaded file path into public url
|
|
63
|
+
def cama_file_path_to_url(file_path)
|
|
64
|
+
file_path.sub(Rails.public_path.to_s, begin
|
|
65
|
+
root_url
|
|
66
|
+
rescue StandardError
|
|
67
|
+
cama_root_url
|
|
68
|
+
end)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# convert public url to file path
|
|
72
|
+
def cama_url_to_file_path(url)
|
|
73
|
+
File.join(Rails.public_path, URI(url.to_s).path)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def slugify(val)
|
|
77
|
+
val.to_s.downcase.strip.tr(' ', '-').gsub(/[^\w-]/, '')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def slugify_folder(val)
|
|
81
|
+
split_folder = val.split('/')
|
|
82
|
+
split_folder[-1] = slugify(split_folder[-1])
|
|
83
|
+
split_folder.join('/')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/camaleon_cms/version.rb
CHANGED
data/lib/camaleon_cms.rb
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
require 'camaleon_cms/engine'
|
|
2
2
|
require 'camaleon_cms/version'
|
|
3
|
+
require 'camaleon_cms/content_security'
|
|
4
|
+
require 'camaleon_cms/unsafe_markup'
|
|
5
|
+
require 'camaleon_cms/svg_content_checker'
|
|
6
|
+
require 'camaleon_cms/media_security_headers'
|
|
7
|
+
require 'camaleon_cms/uploader_content_security'
|
|
8
|
+
require 'camaleon_cms/uploader_path_security'
|
|
9
|
+
require 'camaleon_cms/uploader_pipeline'
|
|
10
|
+
require 'camaleon_cms/uploader_image_processing'
|
|
11
|
+
require 'camaleon_cms/uploader_support'
|
|
12
|
+
require 'camaleon_cms/captcha_image_generation'
|
|
13
|
+
require 'camaleon_cms/task_reporter'
|
|
14
|
+
require 'camaleon_cms/shortcode_registry'
|
|
3
15
|
|
|
4
16
|
module CamaleonCms
|
|
5
17
|
end
|
|
18
|
+
|
|
19
|
+
# Declare core's bundled shortcode names through the boot DSL, then mark the registry available. This
|
|
20
|
+
# runs at require time (before any request), so the save-time shortcode gate can detect shortcodes
|
|
21
|
+
# with no frontend request. If this ever raises the registry stays unavailable and the gate fails
|
|
22
|
+
# closed (non-administrators are refused) rather than fail open on an empty set.
|
|
23
|
+
begin
|
|
24
|
+
CamaleonCms::ShortcodeRegistry.register(*CamaleonCms::ShortcodeRegistry::CORE_SHORTCODES)
|
|
25
|
+
CamaleonCms::ShortcodeRegistry.mark_available!
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
warn("CamaleonCms::ShortcodeRegistry boot registration failed (gate fails closed): #{e.message}")
|
|
28
|
+
end
|
data/lib/ext/hash.rb
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
|
+
require 'cgi'
|
|
2
|
+
|
|
1
3
|
class Hash
|
|
4
|
+
# Attribute names are emitted verbatim, so they are restricted to a conservative subset of what
|
|
5
|
+
# HTML allows rather than escaped: escaping cannot help here, because the characters that turn one
|
|
6
|
+
# name into two (whitespace, `=`, `/`, quotes) are not HTML metacharacters. Covers the forms
|
|
7
|
+
# actually used - `id`, `class`, `data-*`, `aria-*`, `xml:lang`.
|
|
8
|
+
CAMA_HTML_ATTR_NAME = /\A[a-zA-Z_:][-a-zA-Z0-9_:.]*\z/
|
|
9
|
+
|
|
2
10
|
# convert hash to string like class="class val" name='name val'
|
|
11
|
+
#
|
|
12
|
+
# Values are escaped for the HTML attribute context, so no value can close its own attribute and
|
|
13
|
+
# introduce another. `CGI.escapeHTML` is used rather than `ERB::Util.html_escape` deliberately: the
|
|
14
|
+
# latter is a no-op on an `html_safe` value, which would let a caller pass a string whose quote
|
|
15
|
+
# survives into the output. Callers of this method are building attribute values, never markup, so
|
|
16
|
+
# unconditional escaping is the correct contract.
|
|
17
|
+
#
|
|
18
|
+
# Pairs whose key is not a valid attribute name are dropped. A key such as `x onfocus=alert(1) y`
|
|
19
|
+
# would otherwise render as three attributes, so there is no safe way to emit it; anything with a
|
|
20
|
+
# space in it was already producing malformed markup before this guard existed.
|
|
3
21
|
def to_attr_format(split = ' ')
|
|
4
22
|
res = []
|
|
5
23
|
each do |key, value|
|
|
6
|
-
|
|
24
|
+
next unless key.to_s.match?(CAMA_HTML_ATTR_NAME)
|
|
25
|
+
|
|
26
|
+
res << "#{key} = \"#{CGI.escapeHTML(value.to_s)}\""
|
|
7
27
|
end
|
|
8
28
|
res.join(split)
|
|
9
29
|
end
|
|
10
30
|
|
|
11
31
|
# convert hash to attributes for url_path
|
|
32
|
+
#
|
|
33
|
+
# Emits a Ruby fragment (`:key => "value"`) for code generation, not HTML, so it must NOT use the
|
|
34
|
+
# HTML escaper above — entity-encoding would corrupt the generated code. `inspect` produces a
|
|
35
|
+
# complete, correctly escaped double-quoted literal; the previous hand-rolled escape handled the
|
|
36
|
+
# quote but not the backslash, so a value like `a\b` became a backspace and `a\"b` produced a
|
|
37
|
+
# fragment that would not parse.
|
|
12
38
|
def to_attr_url_format
|
|
13
39
|
res = []
|
|
14
40
|
each do |key, value|
|
|
15
|
-
res << ":#{key} =>
|
|
41
|
+
res << ":#{key} => #{value.to_s.inspect}"
|
|
16
42
|
end
|
|
17
43
|
res.join ','
|
|
18
44
|
end
|