Patch by Takashi Kato (@tohosaku). git-svn-id: https://svn.redmine.org/redmine/trunk@22626 e93f8b46-1217-0410-a6f0-8f06a7374b81pull/145/merge
/log/mongrel_debug | /log/mongrel_debug | ||||
/plugins/* | /plugins/* | ||||
!/plugins/README | !/plugins/README | ||||
/public/assets/* | |||||
/public/dispatch.* | /public/dispatch.* | ||||
/public/plugin_assets/* | /public/plugin_assets/* | ||||
/public/themes/* | /public/themes/* |
lib/redmine/scm/adapters/mercurial/redminehelper.pyo | lib/redmine/scm/adapters/mercurial/redminehelper.pyo | ||||
log/*.log* | log/*.log* | ||||
log/mongrel_debug | log/mongrel_debug | ||||
public/assets/* | |||||
public/dispatch.* | public/dispatch.* | ||||
public/plugin_assets/* | public/plugin_assets/* | ||||
tmp/* | tmp/* |
gem 'rbpdf', '~> 1.21.3' | gem 'rbpdf', '~> 1.21.3' | ||||
gem 'addressable' | gem 'addressable' | ||||
gem 'rubyzip', '~> 2.3.0' | gem 'rubyzip', '~> 2.3.0' | ||||
gem 'propshaft', '~> 0.8.0' | |||||
# Ruby Standard Gems | # Ruby Standard Gems | ||||
gem 'csv', '~> 3.2.8' | gem 'csv', '~> 3.2.8' |
plugin = options.delete(:plugin) | plugin = options.delete(:plugin) | ||||
sources = sources.map do |source| | sources = sources.map do |source| | ||||
if plugin | if plugin | ||||
"/plugin_assets/#{plugin}/stylesheets/#{source}" | |||||
"plugin_assets/#{plugin}/#{source}" | |||||
elsif current_theme && current_theme.stylesheets.include?(source) | elsif current_theme && current_theme.stylesheets.include?(source) | ||||
current_theme.stylesheet_path(source) | current_theme.stylesheet_path(source) | ||||
else | else | ||||
# | # | ||||
def image_tag(source, options={}) | def image_tag(source, options={}) | ||||
if plugin = options.delete(:plugin) | if plugin = options.delete(:plugin) | ||||
source = "/plugin_assets/#{plugin}/images/#{source}" | |||||
source = "plugin_assets/#{plugin}/#{source}" | |||||
elsif current_theme && current_theme.images.include?(source) | elsif current_theme && current_theme.images.include?(source) | ||||
source = current_theme.image_path(source) | source = current_theme.image_path(source) | ||||
end | end | ||||
if plugin = options.delete(:plugin) | if plugin = options.delete(:plugin) | ||||
sources = sources.map do |source| | sources = sources.map do |source| | ||||
if plugin | if plugin | ||||
"/plugin_assets/#{plugin}/javascripts/#{source}" | |||||
"plugin_assets/#{plugin}/#{source}" | |||||
else | else | ||||
source | source | ||||
end | end |
# Sets default plugin directory | # Sets default plugin directory | ||||
config.redmine_plugins_directory = 'plugins' | 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 | # Configure log level here so that additional environment file | ||||
# can change it (environments/ENV.rb would take precedence over it) | # can change it (environments/ENV.rb would take precedence over it) | ||||
config.log_level = Rails.env.production? ? :info : :debug | config.log_level = Rails.env.production? ? :info : :debug |
# No email in production log | # No email in production log | ||||
config.action_mailer.logger = nil | config.action_mailer.logger = nil | ||||
config.assets.redmine_detect_update = true | |||||
end | end |
Mime::SET << 'api' | 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 | 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 |
end | end | ||||
Redmine::PluginLoader.load | 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 | 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| | Redmine::FieldFormat::RecordList.subclasses.each do |klass| | ||||
klass.instance.reset_target_class | klass.instance.reset_target_class | ||||
end | 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 | end |
# 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 ActionView::Helpers::TextHelper | ||||
include Rails.application.routes.url_helpers | include Rails.application.routes.url_helpers | ||||
include ApplicationHelper | include ApplicationHelper | ||||
include Propshaft::Helper | |||||
# Default to creating links using only the path. Subclasses can | # Default to creating links using only the path. Subclasses can | ||||
# change this default as needed | # change this default as needed |
path.assets_dir | path.assets_dir | ||||
end | 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) | def <=>(plugin) | ||||
return nil unless plugin.is_a?(Plugin) | return nil unless plugin.is_a?(Plugin) | ||||
end | end | ||||
def stylesheet_path(source) | def stylesheet_path(source) | ||||
"/themes/#{dir}/stylesheets/#{source}" | |||||
"#{asset_prefix}#{source}" | |||||
end | end | ||||
def image_path(source) | def image_path(source) | ||||
"/themes/#{dir}/images/#{source}" | |||||
"#{asset_prefix}#{source}" | |||||
end | end | ||||
def javascript_path(source) | def javascript_path(source) | ||||
"/themes/#{dir}/javascripts/#{source}" | |||||
"#{asset_prefix}#{source}" | |||||
end | end | ||||
def favicon_path | 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 | end | ||||
private | private |
<svg height="100" width="100"> | |||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> | |||||
</svg> |
<svg height="100" width="100"> | |||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> | |||||
</svg> |
.foo { | |||||
background-image: url("../../images/baz/baz.svg"); | |||||
} |
.foo { | |||||
background-image: url("../images/foo.svg"); | |||||
} |
assert_response :success | assert_response :success | ||||
assert_select 'p', :text => 'ContentForInsideHook content' | assert_select 'p', :text => 'ContentForInsideHook content' | ||||
assert_select 'head' do | 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 | ||||
end | end | ||||
# 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 |