From 5b54cd7f766dda19cbfdb02a1f94f45de1c18379 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 13 Jan 2026 11:40:26 +0100 Subject: [PATCH] Add ability to include inline javascript (#37459) --- app/helpers/theme_helper.rb | 16 ++++++++++ app/javascript/inline/theme-selection.js | 23 ++++++++++++++ app/lib/inline_script_manager.rb | 31 +++++++++++++++++++ app/views/layouts/application.html.haml | 1 + spec/requests/content_security_policy_spec.rb | 2 +- 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 app/javascript/inline/theme-selection.js create mode 100644 app/lib/inline_script_manager.rb diff --git a/app/helpers/theme_helper.rb b/app/helpers/theme_helper.rb index 00b4a6d2b3f..1d642056809 100644 --- a/app/helpers/theme_helper.rb +++ b/app/helpers/theme_helper.rb @@ -1,6 +1,22 @@ # frozen_string_literal: true module ThemeHelper + def javascript_inline_tag(path) + entry = InlineScriptManager.instance.file(path) + + # Only add hash if we don't allow arbitrary includes already, otherwise it's going + # to break the React Tools browser extension or other inline scripts + unless Rails.env.development? && request.content_security_policy.dup.script_src.include?("'unsafe-inline'") + request.content_security_policy = request.content_security_policy.clone.tap do |policy| + values = policy.script_src + values << "'sha256-#{entry[:digest]}'" + policy.script_src(*values) + end + end + + content_tag(:script, entry[:contents], type: 'text/javascript') + end + def theme_style_tags(theme) if theme == 'system' ''.html_safe.tap do |tags| diff --git a/app/javascript/inline/theme-selection.js b/app/javascript/inline/theme-selection.js new file mode 100644 index 00000000000..b3a2b03163e --- /dev/null +++ b/app/javascript/inline/theme-selection.js @@ -0,0 +1,23 @@ +(function (element) { + const {userTheme} = element.dataset; + + const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)'); + const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)'); + + const updateColorScheme = () => { + const useDarkMode = userTheme === 'system' ? colorSchemeMediaWatcher.matches : userTheme !== 'mastodon-light'; + element.dataset.mode = useDarkMode ? 'dark' : 'light'; + }; + + const updateContrast = () => { + const useHighContrast = userTheme === 'contrast' || contrastMediaWatcher.matches; + + element.dataset.contrast = useHighContrast ? 'high' : 'default'; + } + + colorSchemeMediaWatcher.addEventListener('change', updateColorScheme); + contrastMediaWatcher.addEventListener('change', updateContrast); + + updateColorScheme(); + updateContrast(); +})(document.documentElement); diff --git a/app/lib/inline_script_manager.rb b/app/lib/inline_script_manager.rb new file mode 100644 index 00000000000..bca7c98f6b7 --- /dev/null +++ b/app/lib/inline_script_manager.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'singleton' + +class InlineScriptManager + include Singleton + include ActionView::Helpers::TagHelper + include ActionView::Helpers::JavaScriptHelper + + def initialize + @cached_files = {} + end + + def file(name) + @cached_files[name] ||= load_file(name) + end + + private + + def load_file(name) + path = Pathname.new(name).cleanpath + raise ArgumentError, "Invalid inline javascript path: #{path}" if path.to_s.start_with?('..') + + path = Rails.root.join('app', 'javascript', 'inline', path) + + contents = javascript_cdata_section(path.read) + digest = Digest::SHA256.base64digest(contents) + + { contents:, digest: } + end +end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 47e602f0f3f..dccb6035cae 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -20,6 +20,7 @@ - if use_mask_icon? %link{ rel: 'mask-icon', href: frontend_asset_path('images/logo-symbol-icon.svg'), color: '#6364FF' }/ %link{ rel: 'manifest', href: manifest_path(format: :json) }/ + = javascript_inline_tag 'theme-selection.js' = theme_color_tags current_theme %meta{ name: 'mobile-web-app-capable', content: 'yes' }/ diff --git a/spec/requests/content_security_policy_spec.rb b/spec/requests/content_security_policy_spec.rb index 0a58a03ffad..0aa4494ef0b 100644 --- a/spec/requests/content_security_policy_spec.rb +++ b/spec/requests/content_security_policy_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'Content-Security-Policy' do img-src 'self' data: blob: #{local_domain} manifest-src 'self' #{local_domain} media-src 'self' data: #{local_domain} - script-src 'self' #{local_domain} 'wasm-unsafe-eval' + script-src 'self' #{local_domain} 'wasm-unsafe-eval' 'sha256-Q/2Cjx8v06hAdOF8/DeBUpsmBcSj7sLN3I/WpTF8T8c=' style-src 'self' #{local_domain} 'nonce-ZbA+JmE7+bK8F5qvADZHuQ==' worker-src 'self' blob: #{local_domain} CSP