decidim-core 0.32.1 → 0.32.2
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/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
- data/app/cells/decidim/following_cell.rb +1 -1
- data/app/cells/decidim/member/show.erb +1 -1
- data/app/cells/decidim/nav_links/show.erb +3 -1
- data/app/cells/decidim/share_text_widget/modal.erb +1 -0
- data/app/cells/decidim/share_widget/modal.erb +1 -0
- data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
- data/app/commands/decidim/create_omniauth_registration.rb +17 -2
- data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
- data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
- data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
- data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
- data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
- data/app/controllers/concerns/decidim/locale_switcher.rb +12 -10
- data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
- data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
- data/app/controllers/concerns/decidim/paginable.rb +7 -3
- data/app/controllers/concerns/decidim/payload_info.rb +2 -0
- data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
- data/app/controllers/decidim/application_controller.rb +0 -6
- data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
- data/app/controllers/decidim/components/base_controller.rb +2 -0
- data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
- data/app/controllers/decidim/favicon_controller.rb +9 -0
- data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
- data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
- data/app/controllers/decidim/homepage_controller.rb +4 -1
- data/app/controllers/decidim/likes_controller.rb +1 -1
- data/app/controllers/decidim/links_controller.rb +2 -0
- data/app/controllers/decidim/manifests_controller.rb +2 -0
- data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
- data/app/controllers/decidim/newsletters_controller.rb +4 -0
- data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
- data/app/controllers/decidim/offline_controller.rb +5 -1
- data/app/controllers/decidim/open_data_controller.rb +5 -1
- data/app/controllers/decidim/pages_controller.rb +31 -3
- data/app/controllers/decidim/private_downloads_controller.rb +2 -0
- data/app/controllers/decidim/profiles_controller.rb +19 -1
- data/app/controllers/decidim/qr_controller.rb +3 -1
- data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
- data/app/controllers/decidim/searches_controller.rb +2 -0
- data/app/controllers/decidim/short_links_controller.rb +4 -0
- data/app/controllers/decidim/static_map_controller.rb +2 -0
- data/app/controllers/decidim/timeouts_controller.rb +4 -0
- data/app/controllers/decidim/tos_controller.rb +2 -0
- data/app/controllers/decidim/upload_validations_controller.rb +4 -2
- data/app/controllers/decidim/user_activities_controller.rb +3 -0
- data/app/forms/decidim/upload_validation_form.rb +16 -10
- data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
- data/app/helpers/decidim/decidim_form_helper.rb +1 -1
- data/app/helpers/decidim/sanitize_helper.rb +11 -1
- data/app/jobs/decidim/application_job.rb +4 -0
- data/app/models/decidim/authorization.rb +24 -0
- data/app/models/decidim/oauth_application.rb +1 -0
- data/app/models/decidim/user.rb +1 -0
- data/app/models/decidim/user_base_entity.rb +1 -1
- data/app/packs/src/decidim/callout.js +10 -0
- data/app/packs/src/decidim/callout.test.js +32 -0
- data/app/packs/src/decidim/controllers/accordion/accordion.test.js +64 -0
- data/app/packs/src/decidim/controllers/accordion/controller.js +43 -0
- data/app/packs/src/decidim/controllers/autofocus/controller.js +30 -0
- data/app/packs/src/decidim/controllers/language_change/controller.js +21 -8
- data/app/packs/src/decidim/controllers/language_change/language_change.test.js +0 -1
- data/app/packs/src/decidim/controllers/mention/controller.js +32 -3
- data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +8 -5
- data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +1 -1
- data/app/packs/src/decidim/datepicker/datepicker_functions.js +41 -25
- data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
- data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +64 -0
- data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
- data/app/packs/src/decidim/editor/common/suggestion.js +32 -22
- data/app/packs/src/decidim/editor/extensions/mention/index.js +14 -3
- data/app/packs/src/decidim/editor/index.js +4 -0
- data/app/packs/src/decidim/editor/test/extensions/mention.test.js +32 -9
- data/app/packs/src/decidim/refactor/implementation/autofocus.js +22 -0
- data/app/packs/src/decidim/refactor/implementation/autofocus.test.js +171 -0
- data/app/packs/src/decidim/utilities/dom.js +2 -2
- data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
- data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
- data/app/packs/stylesheets/decidim/_modal.scss +16 -8
- data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
- data/app/packs/stylesheets/decidim/_participatory_spaces.scss +11 -1
- data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
- data/app/packs/stylesheets/decidim/_typography.scss +5 -2
- data/app/packs/stylesheets/decidim/application.scss +1 -0
- data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
- data/app/permissions/decidim/permissions.rb +20 -0
- data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
- data/app/presenters/decidim/log/base_presenter.rb +10 -1
- data/app/presenters/decidim/log/user_presenter.rb +2 -2
- data/app/presenters/decidim/menu_item_presenter.rb +1 -5
- data/app/presenters/decidim/resource_presenter.rb +11 -0
- data/app/resolvers/decidim/meta_image_url_resolver.rb +7 -1
- data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
- data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
- data/app/services/decidim/svg_content_validator.rb +554 -0
- data/app/uploaders/decidim/application_uploader.rb +2 -2
- data/app/validators/uploader_image_content_validator.rb +276 -0
- data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
- data/app/views/decidim/messaging/conversations/_add_conversation_users.html.erb +1 -1
- data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
- data/app/views/decidim/shared/_login_modal.html.erb +12 -14
- data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
- data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +3 -2
- data/config/initializers/rack_attack.rb +10 -0
- data/config/locales/bg.yml +0 -1
- data/config/locales/ca-IT.yml +3 -1
- data/config/locales/ca.yml +10 -6
- data/config/locales/cs.yml +15 -1
- data/config/locales/de.yml +99 -3
- data/config/locales/en.yml +5 -1
- data/config/locales/es-MX.yml +5 -1
- data/config/locales/es-PY.yml +5 -1
- data/config/locales/es.yml +9 -5
- data/config/locales/eu.yml +161 -160
- data/config/locales/fi-plain.yml +3 -1
- data/config/locales/fi.yml +3 -1
- data/config/locales/fr-CA.yml +5 -1
- data/config/locales/fr.yml +5 -1
- data/config/locales/gl.yml +1485 -33
- data/config/locales/hu.yml +0 -1
- data/config/locales/ja.yml +3 -1
- data/config/locales/pl.yml +0 -1
- data/config/locales/pt-BR.yml +0 -1
- data/config/locales/ro-RO.yml +0 -1
- data/config/locales/sk.yml +2 -1
- data/config/locales/sv.yml +68 -12
- data/config/locales/tr-TR.yml +0 -2
- data/config/routes.rb +11 -2
- data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
- data/decidim-core.gemspec +1 -1
- data/lib/decidim/api/functions/user_entity_list.rb +1 -0
- data/lib/decidim/api/types/user_moderation_type.rb +2 -2
- data/lib/decidim/content_parsers/user_parser.rb +1 -1
- data/lib/decidim/content_parsers.rb +0 -1
- data/lib/decidim/content_renderers/mention_resource_renderer.rb +18 -4
- data/lib/decidim/content_renderers/user_renderer.rb +15 -4
- data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
- data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +58 -0
- data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
- data/lib/decidim/core/version.rb +1 -1
- data/lib/decidim/core.rb +9 -0
- data/lib/decidim/form_builder.rb +9 -1
- data/lib/decidim/has_upload_validations.rb +1 -0
- data/lib/decidim/legacy_form_builder.rb +4 -0
- metadata +12 -7
- data/lib/decidim/content_parsers/inline_images_parser.rb +0 -68
- data/lib/tasks/upgrade/decidim_active_storage_migration_tasks.rake +0 -79
|
@@ -16,10 +16,19 @@ module Decidim
|
|
|
16
16
|
about: resource.user.about,
|
|
17
17
|
reasons: resource.reports.map(&:reason),
|
|
18
18
|
details: resource.reports.map(&:details),
|
|
19
|
-
block_reasons:
|
|
20
|
-
blocking_user:
|
|
19
|
+
block_reasons: blocking&.justification,
|
|
20
|
+
blocking_user: blocking_user&.presenter&.name
|
|
21
21
|
}
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The collection filters on `decidim_users.blocked_at`, but this
|
|
27
|
+
# association is resolved through `decidim_users.block_id`. The two can
|
|
28
|
+
# disagree, in which case there is no block to read the details from.
|
|
29
|
+
def blocking = resource.blocking
|
|
30
|
+
|
|
31
|
+
def blocking_user = blocking&.blocking_user
|
|
23
32
|
end
|
|
24
33
|
end
|
|
25
34
|
end
|
|
@@ -14,7 +14,7 @@ module Decidim
|
|
|
14
14
|
id: resource.id,
|
|
15
15
|
hidden_at: resource.hidden_at,
|
|
16
16
|
report_count: resource.report_count,
|
|
17
|
-
reported_url
|
|
17
|
+
reported_url:,
|
|
18
18
|
reportable_type: resource.decidim_reportable_type,
|
|
19
19
|
reportable_id: resource.decidim_reportable_id,
|
|
20
20
|
reported_content: resource.reported_content,
|
|
@@ -25,6 +25,30 @@ module Decidim
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# `Decidim::Moderation#reportable` is polymorphic, so the database cannot
|
|
32
|
+
# enforce the reference, and the moderation row outlives the resource it
|
|
33
|
+
# points at. Both of the cases below leave the moderation in the export
|
|
34
|
+
# collection with no URL to build.
|
|
35
|
+
def reported_url
|
|
36
|
+
reportable = resource.reportable
|
|
37
|
+
|
|
38
|
+
# The resource is gone. Reportables such as proposals and meetings are
|
|
39
|
+
# soft-deletable while `belongs_to :reportable` does not use
|
|
40
|
+
# `with_deleted`, so moving one to the trash is enough for this.
|
|
41
|
+
return if reportable.nil?
|
|
42
|
+
|
|
43
|
+
# The component is gone but the resource is not: trashing a component
|
|
44
|
+
# does not trash its contents. `ResourceLocatorPresenter#route_proxy`
|
|
45
|
+
# falls back to the resource itself when `component` is nil, and
|
|
46
|
+
# `EngineRouter.main_proxy` then calls `mounted_engine` on it, which
|
|
47
|
+
# only components and participatory spaces respond to.
|
|
48
|
+
return if reportable.is_a?(Decidim::HasComponent) && reportable.component.nil?
|
|
49
|
+
|
|
50
|
+
reportable.reported_content_url
|
|
51
|
+
end
|
|
28
52
|
end
|
|
29
53
|
end
|
|
30
54
|
end
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Decidim
|
|
6
|
+
# Validates the contents of SVG documents.
|
|
7
|
+
#
|
|
8
|
+
# SVG documents are XML-based and can contain scripts and other active
|
|
9
|
+
# content, which is executed when the document is rendered by the browser.
|
|
10
|
+
# Because of that, the contents of files detected as SVG need an additional
|
|
11
|
+
# validation, so that documents which could execute scripts or read external
|
|
12
|
+
# resources are rejected instead of being stored as they are.
|
|
13
|
+
#
|
|
14
|
+
# The document must be parseable as XML with an "svg" root element and must
|
|
15
|
+
# not declare entities nor contain any other content which could execute
|
|
16
|
+
# scripts or read external resources when the document is rendered.
|
|
17
|
+
#
|
|
18
|
+
# The references which load resources into the document (the "href" of
|
|
19
|
+
# "image", "use", "feImage" and "cursor", the URL references of the
|
|
20
|
+
# presentation attributes, the style sheets and the "xml-stylesheet"
|
|
21
|
+
# instruction) are restricted to the document itself and to inline data, so
|
|
22
|
+
# that documents cannot make the browser of the reader fetch third-party
|
|
23
|
+
# resources. The contents of the inline data references declaring
|
|
24
|
+
# an SVG document are validated as nested documents, as they are parsed when
|
|
25
|
+
# the reference is rendered. Links ("a") are not restricted, as they do not
|
|
26
|
+
# load any resource when the document is rendered; they can still reference
|
|
27
|
+
# any host, so they are a phishing surface which is accepted on purpose.
|
|
28
|
+
#
|
|
29
|
+
# This is used by UploaderImageContentValidator, which delegates the SVG
|
|
30
|
+
# specific validation to this class, so callers of that validator only need
|
|
31
|
+
# to call a single validator for all the image formats.
|
|
32
|
+
class SvgContentValidator
|
|
33
|
+
# The elements which are not allowed in SVG documents, because they either
|
|
34
|
+
# execute scripts themselves (e.g. "script" and the SVG Tiny "handler") or
|
|
35
|
+
# allow embedding foreign content which can execute them (e.g.
|
|
36
|
+
# "foreignObject"). The names are kept in lowercase, as the names found in
|
|
37
|
+
# the documents are compared case insensitively.
|
|
38
|
+
SVG_FORBIDDEN_ELEMENTS = %w(script handler foreignobject iframe embed object applet).freeze
|
|
39
|
+
|
|
40
|
+
# The names of the attributes which are not allowed in SVG documents, as
|
|
41
|
+
# event handlers execute scripts when the document is rendered.
|
|
42
|
+
SVG_EVENT_HANDLER_NAME = /\Aon[a-z]/i
|
|
43
|
+
|
|
44
|
+
# The URI prefixes which are not allowed in the attribute values of SVG
|
|
45
|
+
# documents, as they execute scripts.
|
|
46
|
+
SVG_FORBIDDEN_URI_PREFIXES = %w(javascript: livescript: vbscript: data:text/html).freeze
|
|
47
|
+
|
|
48
|
+
# The attributes of the SVG animation elements which define the attribute
|
|
49
|
+
# modified by the animation. Through them an animation can set an event
|
|
50
|
+
# handler or another forbidden value at runtime, so the values of these
|
|
51
|
+
# attributes are validated as element/attribute names. The names are kept
|
|
52
|
+
# in lowercase and compared case insensitively, as the HTML parser resolves
|
|
53
|
+
# the names written in lowercase (e.g. "attributename") to the camel case
|
|
54
|
+
# ones when the document is parsed as HTML.
|
|
55
|
+
SVG_ANIMATION_TARGET_NAMES = %w(attribute attributename attributenames).freeze
|
|
56
|
+
|
|
57
|
+
# The elements which load the resource referenced by their "href" (or
|
|
58
|
+
# legacy "xlink:href") attribute into the document when it is rendered.
|
|
59
|
+
# The references of these elements are restricted to the document itself
|
|
60
|
+
# and to inline data, as absolute references make the browser of the
|
|
61
|
+
# reader fetch resources from third-party hosts, which can be used for
|
|
62
|
+
# tracking or for probing internal networks. Links ("a") are not
|
|
63
|
+
# included, as they do not load any resource; they can still reference
|
|
64
|
+
# any host, so they are a phishing surface which is accepted on purpose.
|
|
65
|
+
SVG_RESOURCE_ELEMENTS = %w(image use feimage cursor).freeze
|
|
66
|
+
|
|
67
|
+
# The presentation attributes whose value defines a reference resolved when
|
|
68
|
+
# the document is rendered. The image references (e.g. "cursor" and "mask")
|
|
69
|
+
# are fetched as resources, and the references to paint servers, filters,
|
|
70
|
+
# clipping paths and markers can also resolve to an element of another
|
|
71
|
+
# document, which is fetched unless the document is processed in a secure
|
|
72
|
+
# mode. Their values are validated as style sheet content, as they are
|
|
73
|
+
# parsed as the CSS properties they define.
|
|
74
|
+
SVG_URL_PRESENTATION_ATTRIBUTES = %w(
|
|
75
|
+
cursor mask fill stroke filter clip-path marker marker-start marker-mid marker-end
|
|
76
|
+
).freeze
|
|
77
|
+
|
|
78
|
+
# The name of the attribute which references the resource loaded by an
|
|
79
|
+
# element, either "href" or the legacy "xlink:href", which is also
|
|
80
|
+
# matched with an undeclared prefix, as browsers resolve it as the XLink
|
|
81
|
+
# reference.
|
|
82
|
+
SVG_REFERENCE_NAME = /\A(?:[\w.-]+:)?href\z/i
|
|
83
|
+
|
|
84
|
+
# The references which load a resource from another host: URIs with a
|
|
85
|
+
# scheme (e.g. "https:") and references resolving to an authority, which
|
|
86
|
+
# browsers also detect from two separators at the beginning of the
|
|
87
|
+
# reference (e.g. "//example.org", "/\example.org" or "\\example.org"), as
|
|
88
|
+
# backslashes are treated as separators of the authority for the schemes of
|
|
89
|
+
# the web. A single backslash only starts a path, so it is not matched.
|
|
90
|
+
# Fragment ("#") and relative references are resolved against the document
|
|
91
|
+
# itself, and "data:" references are inline, so they are accepted (see
|
|
92
|
+
# #forbidden_reference_value?).
|
|
93
|
+
SVG_EXTERNAL_REFERENCE = %r{\A(?!data:)(?:[a-z][a-z0-9+.-]*:|[\\/]{2})}i
|
|
94
|
+
|
|
95
|
+
# The inline data references whose contents are parsed when the document is
|
|
96
|
+
# rendered. The SVG ones are validated as nested documents, as the rules
|
|
97
|
+
# above cannot be applied to a payload which is stored encoded (e.g. in
|
|
98
|
+
# base64), and the other ones are accepted when they do not declare a
|
|
99
|
+
# forbidden content type (see SVG_FORBIDDEN_URI_PREFIXES). The parameters of
|
|
100
|
+
# the MIME type (e.g. "image/svg+xml;charset=utf-8") are matched as well, as
|
|
101
|
+
# browsers still parse the payload as a document, and the "base64" one can
|
|
102
|
+
# appear in any position of the list.
|
|
103
|
+
SVG_DATA_URI = %r{\Adata:(?<mime>image/svg\+xml)(?<parameters>(?:;[^;,]*)*),(?<payload>.*)\z}im
|
|
104
|
+
|
|
105
|
+
# The CSS functions which load the resources they reference when the style
|
|
106
|
+
# sheet is rendered. The strings inside them are interpreted as URLs, so the
|
|
107
|
+
# arguments of "image-set()" define a reference as the arguments of "url()"
|
|
108
|
+
# do, and the vendor prefixed spelling is matched as well.
|
|
109
|
+
CSS_URL_FUNCTIONS = %w(url image-set -webkit-image-set).freeze
|
|
110
|
+
|
|
111
|
+
# The URL references of a style sheet, captured after the comments and the
|
|
112
|
+
# strings which do not define a URL are removed, so that each of them can
|
|
113
|
+
# be validated as a reference. Only the strings of the functions which load
|
|
114
|
+
# their arguments survive the scrubbing, so the remaining strings are URL
|
|
115
|
+
# values, and the unquoted value of a "url()" function is matched as well.
|
|
116
|
+
SVG_CSS_URL_VALUE = /["']([^"')]+)["']?|(?:url|image-set)\(\s*([^"')]+)/im
|
|
117
|
+
|
|
118
|
+
# The maximum depth of the documents nested inside inline data references,
|
|
119
|
+
# which are validated with the same rules as the document itself.
|
|
120
|
+
MAX_NESTED_DEPTH = 5
|
|
121
|
+
|
|
122
|
+
# The maximum size of the contents to validate, as a safety net for the
|
|
123
|
+
# documents which reach this validator without a size limit.
|
|
124
|
+
MAX_CONTENT_SIZE = 10 * 1024 * 1024
|
|
125
|
+
|
|
126
|
+
# The elements which modify the attributes of another element at runtime.
|
|
127
|
+
# When they target a reference attribute of an element which loads the
|
|
128
|
+
# referenced resource, the values assigned to it are validated as
|
|
129
|
+
# references, as the resource is loaded when the animation reaches it. When
|
|
130
|
+
# they target the "style" attribute or one of the presentation attributes
|
|
131
|
+
# which resolve a URL reference, the values are validated as style sheet
|
|
132
|
+
# content, as the URL references they declare are loaded when the animation
|
|
133
|
+
# reaches them.
|
|
134
|
+
SVG_ANIMATION_ELEMENTS = %w(animate set).freeze
|
|
135
|
+
SVG_ANIMATION_VALUE_NAMES = %w(values from to by).freeze
|
|
136
|
+
|
|
137
|
+
# The CSS at-rules which load external resources when the style sheets of
|
|
138
|
+
# an SVG document are rendered. Imports always fetch a resource, so
|
|
139
|
+
# documents containing them are rejected. The URL references are validated
|
|
140
|
+
# separately as references (see SVG_CSS_URL_VALUE), as the same rules apply
|
|
141
|
+
# to them: references with a scheme or an authority fetch a resource from
|
|
142
|
+
# another host, fragment and relative references are resolved against the
|
|
143
|
+
# document itself, and "data:" references are inline but can contain a
|
|
144
|
+
# nested document which is parsed when rendered.
|
|
145
|
+
SVG_CSS_IMPORT = /@import/i
|
|
146
|
+
|
|
147
|
+
# The escape sequences of CSS, which consist of a backslash followed by up
|
|
148
|
+
# to six hexadecimal digits, with an optional whitespace terminating the
|
|
149
|
+
# sequence, or by any other character.
|
|
150
|
+
CSS_ESCAPE = /\\(?:(?<hex>\h{1,6})[ \t\n\r\f]?|(?<char>.))/m
|
|
151
|
+
|
|
152
|
+
# Validates the contents of an SVG document. Returns :valid when the
|
|
153
|
+
# document can be stored, :invalid when the contents are not a well formed
|
|
154
|
+
# SVG document, and :unsafe when the document contains content which could
|
|
155
|
+
# execute scripts or read external resources when it is rendered.
|
|
156
|
+
def self.validate(content)
|
|
157
|
+
new(content).validate
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def initialize(content, depth = 0)
|
|
161
|
+
@content = content
|
|
162
|
+
@depth = depth
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def validate
|
|
166
|
+
return :invalid if content.nil?
|
|
167
|
+
# The contents which are not valid in their encoding cannot be checked by
|
|
168
|
+
# the expressions below, so they are rejected as unsafe.
|
|
169
|
+
return :unsafe unless content.valid_encoding?
|
|
170
|
+
return :invalid if content.blank?
|
|
171
|
+
return :invalid if content.bytesize > MAX_CONTENT_SIZE
|
|
172
|
+
|
|
173
|
+
document = parse
|
|
174
|
+
return :invalid if document.nil?
|
|
175
|
+
return :unsafe if entity_declaration?(document)
|
|
176
|
+
return :unsafe if forbidden_content?(document)
|
|
177
|
+
|
|
178
|
+
:valid
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
attr_reader :content, :depth
|
|
184
|
+
|
|
185
|
+
# Parses the SVG document in strict mode, so that malformed documents are
|
|
186
|
+
# rejected instead of being recovered with a partial tree, and without
|
|
187
|
+
# fetching any external resource (e.g. an external DTD) or substituting
|
|
188
|
+
# entities, so that a malicious document cannot make the server read external
|
|
189
|
+
# resources or expand entities while it is validated. Returns nil when the
|
|
190
|
+
# contents are not a well formed XML document with an "svg" root element.
|
|
191
|
+
def parse
|
|
192
|
+
document = Nokogiri::XML.parse(content) { |options| options.strict.nonet }
|
|
193
|
+
return nil if document.root.nil? || document.root.name != "svg"
|
|
194
|
+
|
|
195
|
+
document
|
|
196
|
+
rescue Nokogiri::XML::SyntaxError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def forbidden_content?(document)
|
|
201
|
+
# The "xml-stylesheet" processing instruction loads an external style
|
|
202
|
+
# sheet when the document is rendered, so documents declaring one are
|
|
203
|
+
# rejected.
|
|
204
|
+
return true if document.xpath("//processing-instruction('xml-stylesheet')").any?
|
|
205
|
+
|
|
206
|
+
document.xpath("//*").any? { |node| forbidden_node?(node) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def forbidden_node?(node)
|
|
210
|
+
return true if SVG_FORBIDDEN_ELEMENTS.include?(node.name.downcase)
|
|
211
|
+
return true if node.attribute_nodes.any? { |attribute| forbidden_attribute?(node, attribute) }
|
|
212
|
+
return true if forbidden_animation_reference?(node)
|
|
213
|
+
|
|
214
|
+
node.name.downcase == "style" && forbidden_css_content?(node.content.to_s)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def forbidden_attribute?(node, attribute)
|
|
218
|
+
return true if SVG_EVENT_HANDLER_NAME.match?(attribute.name)
|
|
219
|
+
|
|
220
|
+
return true if forbidden_uri_value?(attribute.value.to_s)
|
|
221
|
+
return true if css_content_name?(attribute.name) && forbidden_css_content?(attribute.value.to_s)
|
|
222
|
+
return true if animation_target_attribute?(attribute) && forbidden_name?(attribute.value.to_s)
|
|
223
|
+
|
|
224
|
+
forbidden_resource_reference?(node, attribute)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Whether the name is parsed as the content of a style sheet, either because
|
|
228
|
+
# it declares the declarations of an element or because it is a presentation
|
|
229
|
+
# attribute whose CSS property resolves a URL reference (see
|
|
230
|
+
# SVG_URL_PRESENTATION_ATTRIBUTES).
|
|
231
|
+
def css_content_name?(name)
|
|
232
|
+
normalized = name.downcase
|
|
233
|
+
normalized == "style" || SVG_URL_PRESENTATION_ATTRIBUTES.include?(normalized)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Whether the attribute references an external resource in an element
|
|
237
|
+
# which loads it when the document is rendered (e.g. the "href" of an
|
|
238
|
+
# "image").
|
|
239
|
+
def forbidden_resource_reference?(node, attribute)
|
|
240
|
+
return false unless resource_element?(node)
|
|
241
|
+
return false unless SVG_REFERENCE_NAME.match?(attribute.name)
|
|
242
|
+
|
|
243
|
+
forbidden_reference_value?(attribute.value.to_s)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Whether an animation assigns an external resource to a reference
|
|
247
|
+
# attribute (e.g. "href") of an element which loads it, as the resource
|
|
248
|
+
# is loaded when the animation reaches the value. References which do not
|
|
249
|
+
# load a resource (e.g. the links of an "a") are not restricted, as they
|
|
250
|
+
# are never fetched when the document is rendered. The values assigned to
|
|
251
|
+
# the "style" attribute and to the presentation attributes which resolve a
|
|
252
|
+
# URL reference are validated as style sheet content, as the references they
|
|
253
|
+
# declare are loaded when the animation reaches them.
|
|
254
|
+
def forbidden_animation_reference?(node)
|
|
255
|
+
return false unless SVG_ANIMATION_ELEMENTS.include?(node.name.downcase)
|
|
256
|
+
|
|
257
|
+
target = animation_target(node)
|
|
258
|
+
values = animation_values(node)
|
|
259
|
+
return false if target.nil? || values.empty?
|
|
260
|
+
|
|
261
|
+
return true if css_content_name?(target) && forbidden_css_values?(values)
|
|
262
|
+
return false unless SVG_REFERENCE_NAME.match?(target) && animates_resource_element?(node)
|
|
263
|
+
|
|
264
|
+
forbidden_reference_values?(values)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def forbidden_css_values?(values)
|
|
268
|
+
values.any? { |value| forbidden_css_content?(value) }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def forbidden_reference_values?(values)
|
|
272
|
+
values.any? { |value| forbidden_reference_value?(value) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The attribute modified by an animation, normalized as the HTML parser
|
|
276
|
+
# resolves it when the document is parsed as HTML.
|
|
277
|
+
def animation_target(node)
|
|
278
|
+
attribute = node.attribute_nodes.find { |a| animation_target_attribute?(a) }
|
|
279
|
+
attribute&.value&.downcase
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def animation_target_attribute?(attribute)
|
|
283
|
+
SVG_ANIMATION_TARGET_NAMES.any? { |name| attribute.name.casecmp?(name) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The values assigned by an animation, which are accepted as lists
|
|
287
|
+
# separated by semicolons.
|
|
288
|
+
def animation_values(node)
|
|
289
|
+
SVG_ANIMATION_VALUE_NAMES.flat_map { |name| node[name].to_s.split(";") }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Whether the animation modifies the reference of an element which loads
|
|
293
|
+
# the referenced resource when the document is rendered. The animation
|
|
294
|
+
# modifies its parent element, unless it references another one with its
|
|
295
|
+
# own reference attribute (e.g. "xlink:href"), which browsers resolve to
|
|
296
|
+
# the element with that identifier. References which do not resolve to an
|
|
297
|
+
# element of the document are also considered as loading a resource, so
|
|
298
|
+
# that documents which could hide the modified element are rejected.
|
|
299
|
+
def animates_resource_element?(node)
|
|
300
|
+
references = node.attribute_nodes.select { |attribute| SVG_REFERENCE_NAME.match?(attribute.name) }
|
|
301
|
+
return resource_element?(node.parent) if references.empty?
|
|
302
|
+
|
|
303
|
+
references.any? do |attribute|
|
|
304
|
+
targets = referenced_elements(node.document, attribute.value.to_s)
|
|
305
|
+
targets.empty? || targets.any? { |element| resource_element?(element) }
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The elements of the document referenced by a target reference of an
|
|
310
|
+
# animation element, which browsers resolve to the element with the
|
|
311
|
+
# identifier of the fragment, after decoding its percent-encoded characters.
|
|
312
|
+
# The decoded identifier is encoded as UTF-8, as the identifiers of the
|
|
313
|
+
# document are read with that encoding. References to other documents or to
|
|
314
|
+
# missing identifiers do not resolve to any element.
|
|
315
|
+
def referenced_elements(document, reference)
|
|
316
|
+
id = reference[/\A#(.*)\z/m, 1]
|
|
317
|
+
return [] if id.nil?
|
|
318
|
+
|
|
319
|
+
elements_by_id(document).fetch(percent_decode(id).force_encoding(Encoding::UTF_8), [])
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# The elements of the document which declare an identifier, grouped by it.
|
|
323
|
+
# The index is built once per document and reused for every reference, as
|
|
324
|
+
# searching the whole document for each of the animations would take a
|
|
325
|
+
# long time for documents with many elements and animations.
|
|
326
|
+
def elements_by_id(document)
|
|
327
|
+
@elements_by_id ||= {}
|
|
328
|
+
@elements_by_id[document] ||= document.xpath("//*[@id]").group_by { |element| element["id"] }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Whether the element loads the resource referenced by its reference
|
|
332
|
+
# attribute when the document is rendered.
|
|
333
|
+
def resource_element?(element)
|
|
334
|
+
SVG_RESOURCE_ELEMENTS.include?(element.name.downcase)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Whether the reference loads a resource from another host or contains a
|
|
338
|
+
# nested document which could execute scripts or read external resources
|
|
339
|
+
# when it is rendered. The whitespace and control characters which can be
|
|
340
|
+
# used to obfuscate the scheme are removed before checking.
|
|
341
|
+
def forbidden_reference_value?(value)
|
|
342
|
+
return true if SVG_EXTERNAL_REFERENCE.match?(normalize_uri_value(value))
|
|
343
|
+
return true if forbidden_uri_value?(value)
|
|
344
|
+
|
|
345
|
+
nested_document_unsafe?(value)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Whether the contents of an inline data reference declaring an SVG
|
|
349
|
+
# document are unsafe, as they are parsed as a document of their own when
|
|
350
|
+
# the reference is rendered. The nested documents are validated with the
|
|
351
|
+
# same rules, up to a maximum depth, so that a document referencing itself
|
|
352
|
+
# cannot be used to recurse indefinitely. A nested document which cannot be
|
|
353
|
+
# validated because the maximum depth is reached is rejected, as its contents
|
|
354
|
+
# are still parsed when the reference is rendered.
|
|
355
|
+
def nested_document_unsafe?(value)
|
|
356
|
+
match = SVG_DATA_URI.match(value.strip)
|
|
357
|
+
return false if match.nil?
|
|
358
|
+
return true if depth >= MAX_NESTED_DEPTH
|
|
359
|
+
|
|
360
|
+
payload = match[:parameters].downcase.include?(";base64") ? decode_base64(match[:payload]) : percent_decode(match[:payload])
|
|
361
|
+
Decidim::SvgContentValidator.new(payload, depth + 1).validate == :unsafe
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def decode_base64(value)
|
|
365
|
+
value.unpack1("m")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Decodes the percent-encoded characters of a data URI, which are otherwise
|
|
369
|
+
# invisible to the checks applied to the contents of the document. The
|
|
370
|
+
# substitutions are applied to the bytes of the value, as the decoded
|
|
371
|
+
# characters can be part of a multi-byte sequence which is not valid in the
|
|
372
|
+
# encoding of the value. The result is kept as bytes, so that the encoding
|
|
373
|
+
# declared by a nested document is still read by the parser (see #parse);
|
|
374
|
+
# the callers which compare the decoded value with the contents of the
|
|
375
|
+
# document encode it as UTF-8.
|
|
376
|
+
def percent_decode(value)
|
|
377
|
+
value.dup.force_encoding(Encoding::BINARY).gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1).hex].pack("C") }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Whether the value is a script URI or contains one. The animation elements
|
|
381
|
+
# accept lists of values separated by semicolons (e.g. in the "values"
|
|
382
|
+
# attribute), which are assigned to the target attribute one after another
|
|
383
|
+
# at runtime, so each value of the list is checked separately.
|
|
384
|
+
def forbidden_uri_value?(value)
|
|
385
|
+
value.split(";").any? do |uri|
|
|
386
|
+
normalized = normalize_uri_value(uri)
|
|
387
|
+
SVG_FORBIDDEN_URI_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Whether the value targets an event handler or a forbidden element, as
|
|
392
|
+
# used by the animation elements to modify attributes at runtime. Lists of
|
|
393
|
+
# names are also accepted by some animation attributes, so each name of the
|
|
394
|
+
# list is checked separately.
|
|
395
|
+
def forbidden_name?(name)
|
|
396
|
+
name.split(";").any? do |part|
|
|
397
|
+
normalized = part.strip.downcase
|
|
398
|
+
SVG_EVENT_HANDLER_NAME.match?(normalized) || SVG_FORBIDDEN_ELEMENTS.include?(normalized)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Whether the document type definition declares an entity. Entities can be
|
|
403
|
+
# used to read external resources (XXE) or to expand content exponentially
|
|
404
|
+
# (billion laughs), so documents declaring one are rejected. Only the
|
|
405
|
+
# declarations of the internal subset are considered, as the external ones
|
|
406
|
+
# are never fetched (see #parse). Text which only looks like a declaration
|
|
407
|
+
# outside of the document type definition (e.g. inside a comment, a CDATA
|
|
408
|
+
# section or a processing instruction) is not a declaration, so it does not
|
|
409
|
+
# reject a legitimate document.
|
|
410
|
+
def entity_declaration?(document)
|
|
411
|
+
internal_subset = document.internal_subset
|
|
412
|
+
return false if internal_subset.nil?
|
|
413
|
+
|
|
414
|
+
internal_subset.children.any? { |node| node.type == Nokogiri::XML::Node::ENTITY_DECL }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Whether the CSS of a "style" element or attribute loads external
|
|
418
|
+
# resources when the document is rendered. The comments and the strings
|
|
419
|
+
# which do not define a URL are removed, and the escape sequences decoded,
|
|
420
|
+
# before checking, as they can otherwise hide the references (e.g.
|
|
421
|
+
# "u\rl(https://example.org)") or be mistaken for them (e.g. 'content:
|
|
422
|
+
# "See @import instructions"').
|
|
423
|
+
def forbidden_css_content?(css)
|
|
424
|
+
normalized = decode_css_escapes(scrub_css(css))
|
|
425
|
+
return true if SVG_CSS_IMPORT.match?(normalized)
|
|
426
|
+
|
|
427
|
+
normalized.scan(SVG_CSS_URL_VALUE).any? { |groups| forbidden_reference_value?(groups.compact.first) }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Removes the comments and the strings of a style sheet, which are not
|
|
431
|
+
# interpreted as directives when the style sheet is rendered, so that text
|
|
432
|
+
# which only looks like a reference (e.g. 'content: "See @import"') is not
|
|
433
|
+
# mistaken for one. The strings which define the URL of a function which
|
|
434
|
+
# loads its arguments are kept, as those URLs are loaded. Comments and
|
|
435
|
+
# strings are detected following the tokenization rules of CSS, in which
|
|
436
|
+
# comments are not recognized inside strings and escape sequences hide the
|
|
437
|
+
# character following them, so that neither can be used to hide a reference
|
|
438
|
+
# (e.g. 'content: "/*"; background: url(https://example.org)'). The reference
|
|
439
|
+
# of a function is kept after a comment as well, as some engines tokenize the
|
|
440
|
+
# comment inside the function instead of reading it as part of the URL.
|
|
441
|
+
def scrub_css(css)
|
|
442
|
+
scanner = StringScanner.new(css)
|
|
443
|
+
output = +""
|
|
444
|
+
url_functions = []
|
|
445
|
+
until scanner.eos?
|
|
446
|
+
if scanner.scan(%r{/\*})
|
|
447
|
+
scan_css_comment(scanner)
|
|
448
|
+
elsif (quote = scanner.scan(/["']/))
|
|
449
|
+
# The string token is always consumed, as its content is not
|
|
450
|
+
# interpreted as CSS. Only the strings of the functions which load
|
|
451
|
+
# their arguments define a loaded URL.
|
|
452
|
+
string = scan_css_string(scanner, quote)
|
|
453
|
+
output << string if url_functions.any?
|
|
454
|
+
elsif (ident = scan_css_ident(scanner))
|
|
455
|
+
output << ident
|
|
456
|
+
if scanner.match?(/\(/)
|
|
457
|
+
url_functions.push(css_url_function?(ident))
|
|
458
|
+
output << scanner.getch
|
|
459
|
+
end
|
|
460
|
+
else
|
|
461
|
+
char = scanner.getch
|
|
462
|
+
output << char
|
|
463
|
+
# The arguments of these functions are kept until the function ends,
|
|
464
|
+
# as "image-set()" can declare several URLs. The opening parentheses
|
|
465
|
+
# which do not follow an identifier are tracked as well, so that their
|
|
466
|
+
# closing ones do not end an enclosing function and drop its arguments.
|
|
467
|
+
url_functions.push(false) if char == "("
|
|
468
|
+
url_functions.pop if char == ")"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
output
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Consumes the comment token of CSS which starts at the current position.
|
|
475
|
+
# Unterminated comments extend to the end of the style sheet.
|
|
476
|
+
def scan_css_comment(scanner)
|
|
477
|
+
scanner.scan_until(%r{\*/}) || (scanner.pos = scanner.string.length)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Consumes the string token of CSS which starts at the given quote, and
|
|
481
|
+
# returns it with its quotes. Escape sequences hide the following
|
|
482
|
+
# character, and an unescaped newline or the end of the input end the
|
|
483
|
+
# token, as in the tokenization rules of CSS.
|
|
484
|
+
def scan_css_string(scanner, quote)
|
|
485
|
+
token = quote.dup
|
|
486
|
+
until scanner.eos?
|
|
487
|
+
escape = scanner.scan(CSS_ESCAPE)
|
|
488
|
+
if escape
|
|
489
|
+
token << escape
|
|
490
|
+
elsif scanner.match?(quote)
|
|
491
|
+
token << scanner.getch
|
|
492
|
+
break
|
|
493
|
+
elsif scanner.match?(/[\n\r\f]/)
|
|
494
|
+
break
|
|
495
|
+
else
|
|
496
|
+
token << scanner.getch
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
token
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Consumes the identifier token of CSS which starts at the current
|
|
503
|
+
# position, including its escape sequences, and returns it as it is
|
|
504
|
+
# written. Returns nil when no identifier starts at the current position.
|
|
505
|
+
# An identifier starts with a letter, an underscore, a non-ASCII character,
|
|
506
|
+
# an escape sequence, or a hyphen followed by one of those, and continues
|
|
507
|
+
# with any of those or a digit: digits are only accepted after the first
|
|
508
|
+
# character, as "5url(" is a number followed by a function while "url5(" is
|
|
509
|
+
# another function.
|
|
510
|
+
def scan_css_ident(scanner)
|
|
511
|
+
prefix = scanner.scan(/-?(?:[a-zA-Z_]|[^\x00-\x7f])/) || (scanner.match?(CSS_ESCAPE) && "")
|
|
512
|
+
return nil if prefix.nil?
|
|
513
|
+
|
|
514
|
+
ident = prefix.dup
|
|
515
|
+
loop do
|
|
516
|
+
char = scanner.scan(CSS_ESCAPE) || scanner.scan(/[-\w]|[^\x00-\x7f]/)
|
|
517
|
+
break if char.nil?
|
|
518
|
+
|
|
519
|
+
ident << char
|
|
520
|
+
end
|
|
521
|
+
ident
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Whether the identifier is the name of a function which loads its arguments
|
|
525
|
+
# as resources. The name is matched after decoding its escape sequences, as
|
|
526
|
+
# they are processed before the name is interpreted, so that functions
|
|
527
|
+
# written with escapes (e.g. "u\72l(...)") are also recognized.
|
|
528
|
+
def css_url_function?(ident)
|
|
529
|
+
CSS_URL_FUNCTIONS.any? { |name| decode_css_escapes(ident).casecmp?(name) }
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Decodes the escape sequences of CSS. Invalid codepoints are replaced with
|
|
533
|
+
# the replacement character.
|
|
534
|
+
def decode_css_escapes(css)
|
|
535
|
+
css.gsub(CSS_ESCAPE) do
|
|
536
|
+
digits = Regexp.last_match[:hex]
|
|
537
|
+
next Regexp.last_match[:char] if digits.nil?
|
|
538
|
+
|
|
539
|
+
codepoint = digits.to_i(16)
|
|
540
|
+
if codepoint.zero? || codepoint > 0x10FFFF || codepoint.between?(0xD800, 0xDFFF)
|
|
541
|
+
"\uFFFD"
|
|
542
|
+
else
|
|
543
|
+
[codepoint].pack("U")
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Removes the whitespace and control characters which can be used to
|
|
549
|
+
# obfuscate URI schemes (e.g. "java\tscript:") and normalizes the casing.
|
|
550
|
+
def normalize_uri_value(value)
|
|
551
|
+
value.gsub(/[[:space:][:cntrl:]]/, "").downcase
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
@@ -40,7 +40,7 @@ module Decidim
|
|
|
40
40
|
|
|
41
41
|
def url(options = {})
|
|
42
42
|
representable = model.send(mounted_as)
|
|
43
|
-
return
|
|
43
|
+
return unless representable.is_a? ActiveStorage::Attached
|
|
44
44
|
|
|
45
45
|
variant_url(options.delete(:variant), **options)
|
|
46
46
|
end
|
|
@@ -54,7 +54,7 @@ module Decidim
|
|
|
54
54
|
|
|
55
55
|
def path(options = {})
|
|
56
56
|
representable = model.send(mounted_as)
|
|
57
|
-
return
|
|
57
|
+
return unless representable.is_a? ActiveStorage::Attached
|
|
58
58
|
|
|
59
59
|
variant_path(options.delete(:variant), **options)
|
|
60
60
|
end
|