DEV: extract inline js when baking theme fields (#6447)

DEV: extract inline js when baking theme fields (#6447)

  • extract inline js when baking theme fields
  • destroy javascript cache when destroying theme fields

This work is needed to support CSP work

diff --git a/app/controllers/javascripts_controller.rb b/app/controllers/javascripts_controller.rb
new file mode 100644
index 0000000..5e861a3
--- /dev/null
+++ b/app/controllers/javascripts_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+class JavascriptsController < ApplicationController
+  DISK_CACHE_PATH = "#{Rails.root}/tmp/javascript-cache"
+
+  skip_before_action(
+    :check_xhr,
+    :handle_theme,
+    :preload_json,
+    :redirect_to_login_if_required,
+    :verify_authenticity_token,
+    only: [:show]
+  )
+
+  before_action :is_asset_path, :no_cookies, only: [:show]
+
+  def show
+    raise Discourse::NotFound unless last_modified.present?
+    return render body: nil, status: 304 if not_modified?
+
+    # Security: safe due to route constraint
+    cache_file = "#{DISK_CACHE_PATH}/#{params[:digest]}.js"
+
+    unless File.exist?(cache_file)
+      content = query.pluck(:content).first
+      raise Discourse::NotFound if content.nil?
+
+      FileUtils.mkdir_p(DISK_CACHE_PATH)
+      File.write(cache_file, content)
+    end
+
+    set_cache_control_headers
+    send_file(cache_file, disposition: :inline)
+  end
+
+  private
+
+  def query
+    @query ||= JavascriptCache.where(digest: params[:digest]).limit(1)
+  end
+
+  def last_modified
+    @last_modified ||= query.pluck(:updated_at).first
+  end
+
+  def not_modified?
+    cache_time =
+      begin
+        Time.rfc2822(request.env["HTTP_IF_MODIFIED_SINCE"])
+      rescue ArgumentError
+        nil
+      end
+
+    cache_time && last_modified && last_modified <= cache_time
+  end
+
+  def set_cache_control_headers
+    if Rails.env.development?
+      response.headers['Last-Modified'] = Time.zone.now.httpdate
+      immutable_for(1.second)
+    else
+      response.headers['Last-Modified'] = last_modified.httpdate if last_modified
+      immutable_for(1.year)
+    end
+  end
+end
diff --git a/app/models/javascript_cache.rb b/app/models/javascript_cache.rb
new file mode 100644
index 0000000..fd0bbfa
--- /dev/null
+++ b/app/models/javascript_cache.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+class JavascriptCache < ActiveRecord::Base
+  belongs_to :theme_field
+
+  validate :content_cannot_be_nil
+
+  before_save :update_digest
+
+  def url
+    "#{GlobalSetting.cdn_url}#{GlobalSetting.relative_url_root}/javascripts/#{digest}.js"
+  end
+
+  private
+
+  def update_digest
+    self.digest = Digest::SHA1.hexdigest(content) if content_changed?
+  end
+
+  def content_cannot_be_nil
+    errors.add(:content, :empty) if content.nil?
+  end
+end
+
+# == Schema Information
+#
+# Table name: javascript_caches
+#
+#  id             :bigint(8)        not null, primary key
+#  theme_field_id :bigint(8)        not null
+#  digest         :string
+#  content        :text             not null
+#  created_at     :datetime         not null
+#  updated_at     :datetime         not null
+#
+# Indexes
+#
+#  index_javascript_caches_on_digest          (digest)
+#  index_javascript_caches_on_theme_field_id  (theme_field_id)
+#
diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb
index c4d6ab4..84a6b9b 100644
--- a/app/models/theme_field.rb
+++ b/app/models/theme_field.rb
@@ -3,6 +3,7 @@ require_dependency 'theme_settings_parser'
 class ThemeField < ActiveRecord::Base
 
   belongs_to :upload
+  has_one :javascript_cache, dependent: :destroy
 
   scope :find_by_theme_ids, ->(theme_ids) {
     return none unless theme_ids.present?
@@ -68,6 +69,8 @@ PLUGIN_API_JS
 
   def process_html(html)
     errors = nil
+    javascript_cache || build_javascript_cache
+    javascript_cache.content = ''
 
     doc = Nokogiri::HTML.fragment(html)
     doc.css('script[type="text/x-handlebars"]').each do |node|
@@ -83,43 +86,52 @@ PLUGIN_API_JS
 
       if is_raw
         template = "requirejs('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(hbs_template)})"
-        node.replace <<COMPILED
-          <script>
-            (function() {
-              if ('Discourse' in window) {
+        javascript_cache.content << <<COMPILED
+          (function() {
+            if ('Discourse' in window) {
               Discourse.RAW_TEMPLATES[#{name.sub(/\.raw$/, '').inspect}] = #{template};
-              }
-            })();
-          </script>
+            }
+          })();
 COMPILED
       else
         template = "Ember.HTMLBars.template(#{Barber::Ember::Precompiler.compile(hbs_template)})"
-        node.replace <<COMPILED
-          <script>
-            (function() {
-              if ('Em' in window) {
+        javascript_cache.content << <<COMPILED
+          (function() {
+            if ('Em' in window) {
               Ember.TEMPLATES[#{name.inspect}] = #{template};
-              }
-            })();
-          </script>
+            }
+          })();
 COMPILED
       end
 
+      node.remove
     end
 
     doc.css('script[type="text/discourse-plugin"]').each do |node|
       if node['version'].present?
         begin
-          code = transpile(node.inner_html, node['version'])
-          node.replace("<script>#{code}</script>")
+          javascript_cache.content << transpile(node.inner_html, node['version'])
         rescue MiniRacer::RuntimeError => ex
-          node.replace("<script type='text/discourse-js-error'>#{ex.message}</script>")
+          javascript_cache.content << "console.error('Theme Transpilation Error:', #{ex.message.inspect});"
+
           errors ||= []
           errors << ex.message
         end
+
+        node.remove
       end
     end
 
+    doc.css('script').each do |node|
+      next if node['src'].present?
+
+      javascript_cache.content << "(function() { #{node.inner_html} })();"
+      node.remove
+    end
+
+    javascript_cache.save!
+
+    doc.add_child("<script src='#{javascript_cache.url}'></script>") if javascript_cache.content.present?
     [doc.to_s, errors&.join("\n")]
   end
 
diff --git a/config/routes.rb b/config/routes.rb
index 1d6caab..f4769f6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -450,6 +450,7 @@ Discourse::Application.routes.draw do
 
   get "stylesheets/:name.css.map" => "stylesheets#show_source_map", constraints: { name: /[-a-z0-9_]+/ }
   get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ }
+  get "javascripts/:digest.js" => "javascripts#show", constraints: { digest: /\h{40}/ }
 
   post "uploads" => "uploads#create"
   post "uploads/lookup-urls" => "uploads#lookup_urls"
diff --git a/db/migrate/20180927135248_create_javascript_caches.rb b/db/migrate/20180927135248_create_javascript_caches.rb
new file mode 100644
index 0000000..c2b3e83
--- /dev/null
+++ b/db/migrate/20180927135248_create_javascript_caches.rb
@@ -0,0 +1,10 @@
+class CreateJavascriptCaches < ActiveRecord::Migration[5.2]
+  def change
+    create_table :javascript_caches do |t|
+      t.references :theme_field, null: false
+      t.string :digest, null: true, index: true
+      t.text :content, null: false
+      t.timestamps
+    end
+  end
+end
diff --git a/spec/models/javascript_cache_spec.rb b/spec/models/javascript_cache_spec.rb
new file mode 100644
index 0000000..1599fbe
--- /dev/null
+++ b/spec/models/javascript_cache_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe JavascriptCache, type: :model do
+  let(:theme) { Fabricate(:theme) }
+  let(:theme_field) { ThemeField.create!(theme: theme, target_id: 0, name: "header", value: "<a>html</a>") }
+
+  describe '#save' do
+    it 'updates the digest only if the content has changed' do
+      javascript_cache = JavascriptCache.create!(content: 'console.log("hello");', theme_field: theme_field)
+      expect(javascript_cache.digest).to_not be_empty
+
+      expect { javascript_cache.save! }.to_not change { javascript_cache.reload.digest }
+
+      expect do
+        javascript_cache.content = 'console.log("world");'
+        javascript_cache.save!
+      end.to change { javascript_cache.reload.digest }
+    end
+

[... diff too long, it was truncated ...]

GitHub sha: 6acdea37

This commit has been mentioned on Discourse Meta. There might be relevant details there:

This commit has been mentioned on Discourse Meta. There might be relevant details there: