From 0f8e4d7acc51da693444bb7b927458778549ee69 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 1 Sep 2022 09:58:48 +0100 Subject: [PATCH] DEV: Compile splash screen JS with ember-cli before inlining (#18150) This lets us use all our normal JS tooling like prettier, esline and babel on the splash screen JS. At runtime the JS file is read and inlined into the HTML. This commit also switches us to use a CSP hash rather than a nonce for the splash screen. --- .../discourse/scripts/splash-screen.js | 66 ++++++++++++++++++ app/helpers/application_helper.rb | 4 -- app/helpers/splash_screen_helper.rb | 38 +++++++++++ app/views/common/_discourse_splash.html.erb | 67 +------------------ lib/content_security_policy/default.rb | 2 +- spec/requests/application_controller_spec.rb | 8 +-- 6 files changed, 110 insertions(+), 75 deletions(-) create mode 100644 app/assets/javascripts/discourse/scripts/splash-screen.js create mode 100644 app/helpers/splash_screen_helper.rb diff --git a/app/assets/javascripts/discourse/scripts/splash-screen.js b/app/assets/javascripts/discourse/scripts/splash-screen.js new file mode 100644 index 00000000000..a8a56af7eee --- /dev/null +++ b/app/assets/javascripts/discourse/scripts/splash-screen.js @@ -0,0 +1,66 @@ +// This script is inlined in `_discourse_splash.html.erb +const DELAY_TARGET = 2000; +const POLLING_INTERVAL = 50; + +const splashSvgTemplate = document.querySelector(".splash-svg-template"); +const splashTemplateClone = splashSvgTemplate.content.cloneNode(true); +const svgElement = splashTemplateClone.querySelector("svg"); + +const svgString = new XMLSerializer().serializeToString(svgElement); +const encodedSvg = btoa(svgString); + +const splashWrapper = document.querySelector("#d-splash"); +const splashImage = + splashWrapper && splashWrapper.querySelector(".preloader-image"); + +if (splashImage) { + splashImage.src = `data:image/svg+xml;base64,${encodedSvg}`; + + const connectStart = performance.timing.connectStart || 0; + const targetTime = connectStart + DELAY_TARGET; + + let splashInterval; + let discourseReady; + + const swapSplash = () => { + splashWrapper && + splashWrapper.style.setProperty("--animation-state", "running"); + svgElement && svgElement.style.setProperty("--animation-state", "running"); + + const newSvgString = new XMLSerializer().serializeToString(svgElement); + const newEncodedSvg = btoa(newSvgString); + + splashImage.src = `data:image/svg+xml;base64,${newEncodedSvg}`; + + performance.mark("discourse-splash-visible"); + + clearSplashInterval(); + }; + + const clearSplashInterval = () => { + clearInterval(splashInterval); + splashInterval = null; + }; + + (() => { + splashInterval = setInterval(() => { + if (discourseReady) { + clearSplashInterval(); + } + + if (Date.now() > targetTime) { + swapSplash(); + } + }, POLLING_INTERVAL); + })(); + + document.addEventListener( + "discourse-ready", + () => { + discourseReady = true; + splashWrapper && splashWrapper.remove(); + performance.mark("discourse-splash-removed"); + }, + { once: true } + ); +} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 43c5418baf9..fdc40fc444e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -128,10 +128,6 @@ module ApplicationHelper path end - def self.splash_screen_nonce - @splash_screen_nonce ||= SecureRandom.hex - end - def preload_script(script) scripts = [script] diff --git a/app/helpers/splash_screen_helper.rb b/app/helpers/splash_screen_helper.rb new file mode 100644 index 00000000000..7dfc4194565 --- /dev/null +++ b/app/helpers/splash_screen_helper.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module SplashScreenHelper + def self.inline_splash_screen_script + <<~HTML.html_safe + + HTML + end + + def self.fingerprint + if Rails.env.development? + calculate_fingerprint + else + @fingerprint ||= calculate_fingerprint + end + end + + private + + def self.load_js + File.read("#{Rails.root}/app/assets/javascripts/discourse/dist/assets/splash-screen.js").sub("//# sourceMappingURL=splash-screen.map\n", "") + rescue Errno::ENOENT + Rails.logger.error("Unable to load splash screen JS") if Rails.env.production? + "console.log('Unable to load splash screen JS')" + end + + def self.raw_js + if Rails.env.development? + load_js + else + @loaded_js ||= load_js + end + end + + def self.calculate_fingerprint + "sha256-#{Digest::SHA256.base64digest(raw_js)}" + end +end diff --git a/app/views/common/_discourse_splash.html.erb b/app/views/common/_discourse_splash.html.erb index 3ce1009a694..4ce41e78e8e 100644 --- a/app/views/common/_discourse_splash.html.erb +++ b/app/views/common/_discourse_splash.html.erb @@ -246,71 +246,6 @@ - + <%= SplashScreenHelper.inline_splash_screen_script %> <%- end %> diff --git a/lib/content_security_policy/default.rb b/lib/content_security_policy/default.rb index 515dde89017..7245bac31d2 100644 --- a/lib/content_security_policy/default.rb +++ b/lib/content_security_policy/default.rb @@ -75,7 +75,7 @@ class ContentSecurityPolicy end if SiteSetting.splash_screen - sources << "'nonce-#{ApplicationHelper.splash_screen_nonce}'" + sources << "'#{SplashScreenHelper.fingerprint}'" end end end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index c23aece7e98..adc80945adb 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -665,17 +665,17 @@ RSpec.describe ApplicationController do expect(response.body).to include(nonce) end - it 'when splash screen is enabled it adds the same nonce to the policy and the inline splash script' do + it 'when splash screen is enabled it adds the fingerprint to the policy' do SiteSetting.content_security_policy = true SiteSetting.splash_screen = true get '/latest' - nonce = ApplicationHelper.splash_screen_nonce + fingerprint = SplashScreenHelper.fingerprint expect(response.headers).to include('Content-Security-Policy') script_src = parse(response.headers['Content-Security-Policy'])['script-src'] - expect(script_src.to_s).to include(nonce) - expect(response.body).to include(nonce) + expect(script_src.to_s).to include(fingerprint) + expect(response.body).to include(SplashScreenHelper.inline_splash_screen_script) end def parse(csp_string)