From 490e7a8b705ea288926bc3852b08acb80e5373ed Mon Sep 17 00:00:00 2001
From: Takashi Kato <tohosaku@users.osdn.me>
Date: Sat, 10 Sep 2022 14:34:09 +0000
Subject: [PATCH] Add propshaft to enable asset pipeline

---
 .gitignore                                    |   1 +
 .hgignore                                     |   1 +
 Gemfile                                       |   1 +
 app/helpers/application_helper.rb             |   6 +-
 config/application.rb                         |   4 +
 config/environments/production.rb             |   2 +
 config/initializers/10-patches.rb             |  78 +++---
 config/initializers/30-redmine.rb             |  22 +-
 lib/redmine/asset_path.rb                     | 222 ++++++++++++++++++
 lib/redmine/hook/view_listener.rb             |   1 +
 lib/redmine/plugin.rb                         |  12 +
 lib/redmine/themes.rb                         |  20 +-
 .../asset_path/foo/images/baz/baz.svg         |   3 +
 test/fixtures/asset_path/foo/images/foo.svg   |   3 +
 .../asset_path/foo/stylesheets/bar/bar.css    |   3 +
 .../asset_path/foo/stylesheets/foo.css        |   3 +
 test/integration/lib/redmine/hook_test.rb     |   4 +-
 test/unit/lib/redmine/asset_path_test.rb      |  45 ++++
 18 files changed, 364 insertions(+), 67 deletions(-)
 create mode 100644 lib/redmine/asset_path.rb
 create mode 100644 test/fixtures/asset_path/foo/images/baz/baz.svg
 create mode 100644 test/fixtures/asset_path/foo/images/foo.svg
 create mode 100644 test/fixtures/asset_path/foo/stylesheets/bar/bar.css
 create mode 100644 test/fixtures/asset_path/foo/stylesheets/foo.css
 create mode 100644 test/unit/lib/redmine/asset_path_test.rb

diff --git a/.gitignore b/.gitignore
index 5f6413192..f25b049a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@
 /log/mongrel_debug
 /plugins/*
 !/plugins/README
+/public/assets/*
 /public/dispatch.*
 /public/plugin_assets/*
 /public/themes/*
diff --git a/.hgignore b/.hgignore
index 48a0a5589..3e7354f7c 100644
--- a/.hgignore
+++ b/.hgignore
@@ -24,6 +24,7 @@ lib/redmine/scm/adapters/mercurial/redminehelper.pyc
 lib/redmine/scm/adapters/mercurial/redminehelper.pyo
 log/*.log*
 log/mongrel_debug
+public/assets/*
 public/dispatch.*
 public/plugin_assets/*
 tmp/*
diff --git a/Gemfile b/Gemfile
index 1520383fa..f7ce9633d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -21,6 +21,7 @@ gem 'net-imap', '~> 0.3.4'
 gem 'net-pop', '~> 0.1.2'
 gem 'net-smtp', '~> 0.3.3'
 gem 'rexml', require: false if Gem.ruby_version >= Gem::Version.new('3.0')
+gem 'propshaft', '~> 0.8.0'
 
 # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
 gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index ac7cc3c50..74bfc4422 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1668,7 +1668,7 @@ module ApplicationHelper
     plugin = options.delete(:plugin)
     sources = sources.map do |source|
       if plugin
-        "/plugin_assets/#{plugin}/stylesheets/#{source}"
+        "plugin_assets/#{plugin}/#{source}"
       elsif current_theme && current_theme.stylesheets.include?(source)
         current_theme.stylesheet_path(source)
       else
@@ -1685,7 +1685,7 @@ module ApplicationHelper
   #
   def image_tag(source, options={})
     if plugin = options.delete(:plugin)
-      source = "/plugin_assets/#{plugin}/images/#{source}"
+      source = "plugin_assets/#{plugin}/#{source}"
     elsif current_theme && current_theme.images.include?(source)
       source = current_theme.image_path(source)
     end
@@ -1702,7 +1702,7 @@ module ApplicationHelper
     if plugin = options.delete(:plugin)
       sources = sources.map do |source|
         if plugin
-          "/plugin_assets/#{plugin}/javascripts/#{source}"
+          "plugin_assets/#{plugin}/#{source}"
         else
           source
         end
diff --git a/config/application.rb b/config/application.rb
index f3b6e82fd..069796185 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -88,6 +88,10 @@ module RedmineApp
     # Sets default plugin directory
     config.redmine_plugins_directory = 'plugins'
 
+    # Paths for plugin and theme assets. Nothing is set here, as the actual
+    # configuration is performed in the initializer.
+    config.assets.redmine_extension_paths = []
+
     # Configure log level here so that additional environment file
     # can change it (environments/ENV.rb would take precedence over it)
     config.log_level = Rails.env.production? ? :info : :debug
diff --git a/config/environments/production.rb b/config/environments/production.rb
index edbffba9e..acba108ca 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -93,4 +93,6 @@ Rails.application.configure do
 
   # No email in production log
   config.action_mailer.logger = nil
+
+  config.assets.redmine_detect_update = true
 end
diff --git a/config/initializers/10-patches.rb b/config/initializers/10-patches.rb
index 9a3238963..f9c1e9bd0 100644
--- a/config/initializers/10-patches.rb
+++ b/config/initializers/10-patches.rb
@@ -164,57 +164,6 @@ end
 
 Mime::SET << 'api'
 
-# Adds asset_id parameters to assets like Rails 3 to invalidate caches in browser
-module ActionView
-  module Helpers
-    module AssetUrlHelper
-      @@cache_asset_timestamps = Rails.env.production?
-      @@asset_timestamps_cache = {}
-      @@asset_timestamps_cache_guard = Mutex.new
-
-      def asset_path_with_asset_id(source, options = {})
-        asset_id = rails_asset_id(source, options)
-        unless asset_id.blank?
-          source += "?#{asset_id}"
-        end
-        asset_path(source, options.merge(skip_pipeline: true))
-      end
-      alias :path_to_asset :asset_path_with_asset_id
-
-      def rails_asset_id(source, options = {})
-        if asset_id = ENV["RAILS_ASSET_ID"]
-          asset_id
-        else
-          if @@cache_asset_timestamps && (asset_id = @@asset_timestamps_cache[source])
-            asset_id
-          else
-            extname = compute_asset_extname(source, options)
-            path = File.join(Rails.public_path, "#{source}#{extname}")
-            exist = false
-            if File.exist? path
-              exist = true
-            else
-              path = File.join(Rails.public_path, public_compute_asset_path("#{source}#{extname}", options))
-              if File.exist? path
-                exist = true
-              end
-            end
-            asset_id = exist ? File.mtime(path).to_i.to_s : ''
-
-            if @@cache_asset_timestamps
-              @@asset_timestamps_cache_guard.synchronize do
-                @@asset_timestamps_cache[source] = asset_id
-              end
-            end
-
-            asset_id
-          end
-        end
-      end
-    end
-  end
-end
-
 # https://github.com/rack/rack/pull/1703
 # TODO: remove this when Rack is updated to 3.0.0
 require 'rack'
@@ -228,3 +177,30 @@ module Rack
     end
   end
 end
+
+module Propshaft
+  Assembly.prepend(Module.new do
+    def initialize(config)
+      super
+      if Rails.application.config.assets.redmine_detect_update && (!manifest_path.exist? || manifest_outdated?)
+        processor.process
+      end
+    end
+
+    def manifest_outdated?
+      !!load_path.asset_files.detect{|f| f.mtime > manifest_path.mtime}
+    end
+
+    def load_path
+      @load_path ||= Redmine::AssetLoadPath.new(config)
+    end
+  end)
+
+  Helper.prepend(Module.new do
+    def compute_asset_path(path, options = {})
+      super
+    rescue MissingAssetError => e
+      File.join Rails.application.assets.resolver.prefix, path
+    end
+  end)
+end
diff --git a/config/initializers/30-redmine.rb b/config/initializers/30-redmine.rb
index fc36977f6..6cf6ddbe9 100644
--- a/config/initializers/30-redmine.rb
+++ b/config/initializers/30-redmine.rb
@@ -18,17 +18,25 @@ if secret.present?
 end
 
 Redmine::PluginLoader.load
-plugin_assets_reloader = Redmine::PluginLoader.create_assets_reloader
-
-Rails.application.reloaders << plugin_assets_reloader
-unless Redmine::Configuration['mirror_plugins_assets_on_startup'] == false
-  plugin_assets_reloader.execute
-end
 
 Rails.application.config.to_prepare do
+  default_paths = []
+  default_paths << Rails.public_path.join('javascripts')
+  default_paths << Rails.public_path.join('stylesheets')
+  default_paths << Rails.public_path.join('images')
+  Rails.application.config.assets.redmine_default_asset_path = Redmine::AssetPath.new(Rails.public_path, default_paths)
+
   Redmine::FieldFormat::RecordList.subclasses.each do |klass|
     klass.instance.reset_target_class
   end
 
-  plugin_assets_reloader.execute_if_updated
+  Redmine::Plugin.all.each do |plugin|
+    paths = plugin.asset_paths
+    Rails.application.config.assets.redmine_extension_paths << paths if paths.present?
+  end
+
+  Redmine::Themes.themes.each do |theme|
+    paths = theme.asset_paths
+    Rails.application.config.assets.redmine_extension_paths << paths if paths.present?
+  end
 end
diff --git a/lib/redmine/asset_path.rb b/lib/redmine/asset_path.rb
new file mode 100644
index 000000000..d32dbd479
--- /dev/null
+++ b/lib/redmine/asset_path.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+# Redmine - project management software
+# Copyright (C) 2006-2023  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  class AssetPath
+
+    attr_reader :paths, :prefix, :version
+
+    def initialize(base_dir, paths, prefix=nil)
+      @base_dir = base_dir
+      @paths  = paths
+      @prefix = prefix
+      @transition = Transition.new(src: Set.new, dest: Set.new)
+      @version = Rails.application.config.assets.version
+    end
+
+    def update(transition_map:, assets:)
+      each_file do |file, intermediate_path, logical_path|
+        @transition.add_src  intermediate_path, logical_path
+        @transition.add_dest intermediate_path, logical_path
+
+        asset = file.extname == '.css' ? Redmine::Asset.new(file,   logical_path: logical_path, version: version, transition_map: transition_map)
+                                       : Propshaft::Asset.new(file, logical_path: logical_path, version: version)
+        assets[asset.logical_path.to_s] ||= asset
+      end
+      @transition.update(transition_map)
+      nil
+    end
+
+    def each_file
+      paths.each do |path|
+        without_dotfiles(all_files_from_tree(path)).each do |file|
+          relative_path = file.relative_path_from(path).to_s
+          logical_path  = prefix ? File.join(prefix, relative_path) : relative_path
+          intermediate_path = Pathname.new("/#{prefix}").join(file.relative_path_from(@base_dir))
+          yield file, intermediate_path, logical_path
+        end
+      end
+    end
+
+    private
+
+    Transition = Struct.new(:src, :dest, keyword_init: true) do
+
+      def add_src(file, logical_path)
+        src.add  path_pair(file, logical_path) if file.extname == '.css'
+      end
+
+      def add_dest(file, logical_path)
+        return if file.extname == '.js' || file.extname == '.map'
+
+        # No parent-child directories are needed in dest.
+        dirname = file.dirname
+        if child = dest.find{|d| child_path? dirname, d[0]}
+          dest.delete child
+          dest.add path_pair(file, logical_path)
+        elsif !dest.any?{|d| parent_path? dirname, d[0]}
+          dest.add path_pair(file, logical_path)
+        end
+      end
+
+      def path_pair(file, logical_path)
+        [file.dirname, Pathname.new("/#{logical_path}").dirname]
+      end
+
+      def parent_path?(path, other)
+        return nil if other == path
+
+        path.ascend.any?{|v| v == other}
+      end
+
+      def child_path?(path, other)
+        return nil if path == other
+
+        other.ascend.any?{|v| v == path}
+      end
+
+      def update(transition_map)
+        product = src.to_a.product(dest.to_a).select{|t| t[0] != t[1]}
+        maps = product.map do |t|
+          AssetPathMap.new(src: t[0][0], dest: t[1][0], logical_src: t[0][1], logical_dest: t[1][1])
+        end
+
+        maps.each do |m|
+          if m.before != m.after
+            transition_map[m.dirname] ||= {}
+            transition_map[m.dirname][m.before] = m.after
+          end
+        end
+      end
+    end
+
+    AssetPathMap = Struct.new(:src, :dest, :logical_src, :logical_dest, keyword_init: true) do
+
+      def dirname
+        key = logical_src.to_s.sub('/', '')
+        key == '' ? '.' : key
+      end
+
+      def before
+        dest.relative_path_from(src).to_s
+      end
+
+      def after
+        logical_dest.relative_path_from(logical_src).to_s
+      end
+    end
+
+    def without_dotfiles(files)
+      files.reject { |file| file.basename.to_s.starts_with?(".") }
+    end
+
+    def all_files_from_tree(path)
+      path.children.flat_map { |child| child.directory? ? all_files_from_tree(child) : child }
+    end
+  end
+
+  class AssetLoadPath < Propshaft::LoadPath
+
+    attr_reader :extension_paths, :default_asset_path, :transition_map
+
+    def initialize(config)
+      @extension_paths    = config.redmine_extension_paths
+      @default_asset_path = config.redmine_default_asset_path
+
+      super(config.paths, version: config.version)
+    end
+
+    def asset_files
+      Enumerator.new do |y|
+        Rails.logger.info all_paths
+        all_paths.each do |path|
+          next unless path.exist?
+          without_dotfiles(all_files_from_tree(path)).each do |file|
+            y << file
+          end
+        end
+      end
+    end
+
+    def assets_by_path
+      merge_required = @cached_assets_by_path == nil
+
+      super
+
+      if merge_required
+        @transition_map = {}
+        default_asset_path.update(assets: @cached_assets_by_path, transition_map: transition_map)
+
+        extension_paths.each do |asset_path|
+          # Support link from extension assets to assets in the application
+          default_asset_path.each_file do |file, intermediate_path, logical_path|
+            asset_path.instance_eval { @transition.add_dest intermediate_path, logical_path }
+          end
+          asset_path.update(assets: @cached_assets_by_path, transition_map: transition_map)
+        end
+      end
+      @cached_assets_by_path
+    end
+
+    def cache_sweeper
+      @cache_sweeper ||= begin
+        exts_to_watch  = Mime::EXTENSION_LOOKUP.map(&:first)
+        files_to_watch = Array(all_paths).collect { |dir| [ dir.to_s, exts_to_watch ] }.to_h
+
+        Rails.application.config.file_watcher.new([], files_to_watch) do
+          clear_cache
+        end
+      end
+    end
+
+    def all_paths
+      [paths, default_asset_path.paths, extension_paths.map{|path| path.paths}].flatten.compact
+    end
+
+    def clear_cache
+      @transition_map = nil
+      super
+    end
+  end
+
+  class Asset < Propshaft::Asset
+
+    def initialize(file, logical_path:, version:, transition_map:)
+      @transition_map = transition_map
+      super(file, logical_path: logical_path, version: version)
+    end
+
+    def content
+      if conversion = @transition_map[logical_path.dirname.to_s]
+        convert_path super, conversion
+      else
+        super
+      end
+    end
+
+    ASSET_URL_PATTERN = /(url\(\s*["']?([^"'\s)]+)\s*["']?\s*\))/
+    def convert_path(input, conversion)
+      input.gsub(ASSET_URL_PATTERN) do |matched|
+        conversion.each do |key, val|
+          matched.sub!(key, val)
+        end
+        matched
+      end
+    end
+  end
+end
diff --git a/lib/redmine/hook/view_listener.rb b/lib/redmine/hook/view_listener.rb
index 2a9efd34a..225112903 100644
--- a/lib/redmine/hook/view_listener.rb
+++ b/lib/redmine/hook/view_listener.rb
@@ -34,6 +34,7 @@ module Redmine
       include ActionView::Helpers::TextHelper
       include Rails.application.routes.url_helpers
       include ApplicationHelper
+      include Propshaft::Helper
 
       # Default to creating links using only the path.  Subclasses can
       # change this default as needed
diff --git a/lib/redmine/plugin.rb b/lib/redmine/plugin.rb
index 8107a5a39..c9b2c07ba 100644
--- a/lib/redmine/plugin.rb
+++ b/lib/redmine/plugin.rb
@@ -186,6 +186,18 @@ module Redmine
       path.assets_dir
     end
 
+    def asset_prefix
+      File.join(self.class.public_directory.basename, id.to_s)
+    end
+
+    def asset_paths
+      if path.has_assets_dir?
+        base_dir = Pathname.new(path.assets_dir)
+        paths = base_dir.children.filter_map{|child| child if child.directory? }
+        Redmine::AssetPath.new(base_dir, paths, asset_prefix)
+      end
+    end
+
     def <=>(plugin)
       return nil unless plugin.is_a?(Plugin)
 
diff --git a/lib/redmine/themes.rb b/lib/redmine/themes.rb
index ff51f0083..ab9b1d717 100644
--- a/lib/redmine/themes.rb
+++ b/lib/redmine/themes.rb
@@ -91,19 +91,31 @@ module Redmine
       end
 
       def stylesheet_path(source)
-        "/themes/#{dir}/stylesheets/#{source}"
+        "#{asset_prefix}#{source}"
       end
 
       def image_path(source)
-        "/themes/#{dir}/images/#{source}"
+        "#{asset_prefix}#{source}"
       end
 
       def javascript_path(source)
-        "/themes/#{dir}/javascripts/#{source}"
+        "#{asset_prefix}#{source}"
       end
 
       def favicon_path
-        "/themes/#{dir}/favicon/#{favicon}"
+        "#{asset_prefix}#{favicon}"
+      end
+
+      def asset_prefix
+        "themes/#{dir}/"
+      end
+
+      def asset_paths
+        base_dir = Pathname.new(path)
+        paths = base_dir.children.filter_map{|child| child if child.directory? &&
+                                                              child.basename.to_s != "src" &&
+                                                              !child.basename.to_s.start_with?('.') }
+        Redmine::AssetPath.new(base_dir, paths, asset_prefix)
       end
 
       private
diff --git a/test/fixtures/asset_path/foo/images/baz/baz.svg b/test/fixtures/asset_path/foo/images/baz/baz.svg
new file mode 100644
index 000000000..5fe0a4a07
--- /dev/null
+++ b/test/fixtures/asset_path/foo/images/baz/baz.svg
@@ -0,0 +1,3 @@
+<svg height="100" width="100">
+  <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+</svg>
diff --git a/test/fixtures/asset_path/foo/images/foo.svg b/test/fixtures/asset_path/foo/images/foo.svg
new file mode 100644
index 000000000..5fe0a4a07
--- /dev/null
+++ b/test/fixtures/asset_path/foo/images/foo.svg
@@ -0,0 +1,3 @@
+<svg height="100" width="100">
+  <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+</svg>
diff --git a/test/fixtures/asset_path/foo/stylesheets/bar/bar.css b/test/fixtures/asset_path/foo/stylesheets/bar/bar.css
new file mode 100644
index 000000000..49611eb81
--- /dev/null
+++ b/test/fixtures/asset_path/foo/stylesheets/bar/bar.css
@@ -0,0 +1,3 @@
+.foo {
+  background-image: url("../../images/baz/baz.svg");
+}
diff --git a/test/fixtures/asset_path/foo/stylesheets/foo.css b/test/fixtures/asset_path/foo/stylesheets/foo.css
new file mode 100644
index 000000000..d66f1433d
--- /dev/null
+++ b/test/fixtures/asset_path/foo/stylesheets/foo.css
@@ -0,0 +1,3 @@
+.foo {
+  background-image: url("../images/foo.svg");
+}
diff --git a/test/integration/lib/redmine/hook_test.rb b/test/integration/lib/redmine/hook_test.rb
index 9eacded52..e7f2fa35a 100644
--- a/test/integration/lib/redmine/hook_test.rb
+++ b/test/integration/lib/redmine/hook_test.rb
@@ -102,8 +102,8 @@ class HookTest < Redmine::IntegrationTest
     assert_response :success
     assert_select 'p', :text => 'ContentForInsideHook content'
     assert_select 'head' do
-      assert_select 'script[src="/plugin_assets/test_plugin/javascripts/test_plugin.js"]'
-      assert_select 'link[href="/plugin_assets/test_plugin/stylesheets/test_plugin.css"]'
+      assert_select 'script[src="/assets/plugin_assets/test_plugin/test_plugin.js"]'
+      assert_select 'link[href="/assets/plugin_assets/test_plugin/test_plugin.css"]'
     end
   end
 
diff --git a/test/unit/lib/redmine/asset_path_test.rb b/test/unit/lib/redmine/asset_path_test.rb
new file mode 100644
index 000000000..56528a449
--- /dev/null
+++ b/test/unit/lib/redmine/asset_path_test.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-2023  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require_relative '../../../test_helper'
+
+class Redmine::AssetPathTest < ActiveSupport::TestCase
+  def setup
+    assets_dir = Rails.root.join('test/fixtures/asset_path/foo')
+    @asset_path = Redmine::AssetPath.new(assets_dir, assets_dir.children.filter_map{|child| child if child.directory? }, 'plugin_assets/foo/')
+    @assets = {}
+    @transition_map = {}
+    @asset_path.update(transition_map: @transition_map, assets: @assets)
+  end
+
+  test "asset path size" do
+    assert_equal 2, @asset_path.paths.size
+  end
+
+  test "@transition_map does not contain directories with parent-child relationships" do
+    assert_equal '.', @transition_map['plugin_assets/foo']['../images']
+    assert_nil   @transition_map['plugin_assets/foo/bar']['../../images/baz']
+    assert_equal '..', @transition_map['plugin_assets/foo/bar']['../../images']
+  end
+
+  test "update assets" do
+    assert_not_nil @assets['plugin_assets/foo/foo.css']
+    assert_not_nil @assets['plugin_assets/foo/foo.svg']
+  end
+end
-- 
2.39.2

