/log/mongrel_debug
/plugins/*
!/plugins/README
+/public/assets/*
/public/dispatch.*
/public/plugin_assets/*
/public/themes/*
lib/redmine/scm/adapters/mercurial/redminehelper.pyo
log/*.log*
log/mongrel_debug
+public/assets/*
public/dispatch.*
public/plugin_assets/*
tmp/*
gem 'rbpdf', '~> 1.21.3'
gem 'addressable'
gem 'rubyzip', '~> 2.3.0'
+gem 'propshaft', '~> 0.8.0'
# Ruby Standard Gems
gem 'csv', '~> 3.2.8'
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
#
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
if plugin = options.delete(:plugin)
sources = sources.map do |source|
if plugin
- "/plugin_assets/#{plugin}/javascripts/#{source}"
+ "plugin_assets/#{plugin}/#{source}"
else
source
end
# 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
# No email in production log
config.action_mailer.logger = nil
+
+ config.assets.redmine_detect_update = true
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
+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
- 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
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
--- /dev/null
+# 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
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
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)
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
--- /dev/null
+<svg height="100" width="100">
+ <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+</svg>
--- /dev/null
+<svg height="100" width="100">
+ <circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
+</svg>
--- /dev/null
+.foo {
+ background-image: url("../../images/baz/baz.svg");
+}
--- /dev/null
+.foo {
+ background-image: url("../images/foo.svg");
+}
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
--- /dev/null
+# 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