From 61ca7f9796aeceed32e4105e90865bae2bef37ff Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Wed, 30 Sep 2026 13:56:37 +0900 Subject: [PATCH 1/2] Add a rake task that installs mermaid.js --- .gitignore | 1 + app/controllers/admin_controller.rb | 3 +- config/locales/en.yml | 1 + doc/INSTALL | 27 +++- doc/UPGRADING | 17 ++- lib/redmine/mermaid.rb | 178 ++++++++++++++++++++++++++ lib/tasks/mermaid.rake | 67 ++++++++++ test/test_helper.rb | 8 ++ test/unit/lib/redmine/mermaid_test.rb | 132 +++++++++++++++++++ 9 files changed, 425 insertions(+), 9 deletions(-) create mode 100644 lib/redmine/mermaid.rb create mode 100644 lib/tasks/mermaid.rake create mode 100644 test/unit/lib/redmine/mermaid_test.rb diff --git a/.gitignore b/.gitignore index 7765cd0c7..11e904d14 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ /tmp/test/* /tmp/thumbnails/* /vendor/cache +/vendor/javascript/mermaid.min.js *.rbc /.bundle diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 1b79cdc55..c2f2c1d85 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -85,7 +85,8 @@ class AdminController < ApplicationController [:text_minimagick_available, Object.const_defined?(:MiniMagick)], [:text_convert_available, Redmine::Thumbnail.convert_available?], [:text_gs_available, Redmine::Thumbnail.gs_available?], - [:text_pandoc_available, Redmine::Markdownizer.available?] + [:text_pandoc_available, Redmine::Markdownizer.available?], + [:text_mermaid_available, Redmine::Mermaid.available?] ] @checklist << [:text_default_active_job_queue_changed, Rails.application.config.active_job.queue_adapter != :async] if Rails.env.production? end diff --git a/config/locales/en.yml b/config/locales/en.yml index d8feafd60..41ba8645e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1350,6 +1350,7 @@ en: text_convert_available: ImageMagick convert available (optional) text_gs_available: ImageMagick PDF support available (optional) text_pandoc_available: Pandoc available (optional) + text_mermaid_available: Mermaid.js available (optional) text_default_active_job_queue_changed: Default queue adapter which is well suited only for dev/test changed text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?" text_destroy_time_entries: Delete reported hours diff --git a/doc/INSTALL b/doc/INSTALL index e20e960ce..f1ea1dece 100644 --- a/doc/INSTALL +++ b/doc/INSTALL @@ -80,14 +80,33 @@ The current version of Firefox, Safari, Chrome, Chromium and Microsoft Edge. removing the public/assets directory before re-running the precompile: bin/rails assets:clobber RAILS_ENV="production" -7. Create the database structure +7. Install Mermaid.js (optional) + + Redmine can render ```mermaid code blocks in formatted text as diagrams + with Mermaid.js (https://mermaid.js.org/). Mermaid.js is not bundled with + Redmine. To install it, run: + bin/rails redmine:mermaid:install RAILS_ENV="production" + + This downloads the mermaid package from registry.npmjs.org, verifies its + checksum and places mermaid.min.js into the vendor/javascript directory. + On a server without internet access, download the package on another + machine and point FILE to it: + bin/rails redmine:mermaid:install FILE=/path/to/mermaid.tgz RAILS_ENV="production" + + Restart the application after installing. If automatic asset compilation + is disabled, run assets:precompile again as well. The availability of + Mermaid.js is shown in "Administration -> Information", and it can be + removed with: + bin/rails redmine:mermaid:uninstall RAILS_ENV="production" + +8. Create the database structure Under the application main directory run: bin/rails db:migrate RAILS_ENV="production" It will create all the tables and an administrator account. -8. Setting up permissions (Windows users have to skip this section) +9. Setting up permissions (Windows users have to skip this section) The user who runs Redmine must have write permission on the following subdirectories: files, log, tmp & public/assets. @@ -96,7 +115,7 @@ The current version of Firefox, Safari, Chrome, Chromium and Microsoft Edge. sudo chown -R redmine:redmine files log tmp public/assets sudo chmod -R 755 files log tmp public/assets -9. Test the installation by running the Puma web server +10. Test the installation by running the Puma web server Under the main application directory run: ruby bin/rails server -e production @@ -104,7 +123,7 @@ The current version of Firefox, Safari, Chrome, Chromium and Microsoft Edge. Once Puma has started, point your browser to http://localhost:3000/ You should now see the application welcome page. -10. Use the default administrator account to log in: +11. Use the default administrator account to log in: login: admin password: admin diff --git a/doc/UPGRADING b/doc/UPGRADING index a754a7d4f..04717e8ff 100644 --- a/doc/UPGRADING +++ b/doc/UPGRADING @@ -76,7 +76,16 @@ https://www.redmine.org/ removing the public/assets directory before re-running the precompile: bin/rails assets:clobber RAILS_ENV="production" -8. Migrate your database +8. Install or update Mermaid.js (optional) + + If you use Mermaid.js diagram rendering, run the following command again, + as the version of Mermaid.js installed by default may change between + Redmine releases: + bin/rails redmine:mermaid:install RAILS_ENV="production" + + See doc/INSTALL for details. + +9. Migrate your database Please make a backup before doing this! Under the new application directory run: @@ -86,12 +95,12 @@ https://www.redmine.org/ migrations using: bin/rails redmine:plugins:migrate RAILS_ENV="production" -9. Clear the cache and the existing sessions by running: +10. Clear the cache and the existing sessions by running: bin/rails tmp:cache:clear tmp:sessions:clear -10. Restart the application server (e.g. mongrel, thin, passenger) +11. Restart the application server (e.g. mongrel, thin, passenger) -11. Finally go to "Administration -> Roles & permissions" to check/set permissions +12. Finally go to "Administration -> Roles & permissions" to check/set permissions for new features, if any == References diff --git a/lib/redmine/mermaid.rb b/lib/redmine/mermaid.rb new file mode 100644 index 000000000..13d248219 --- /dev/null +++ b/lib/redmine/mermaid.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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 'digest/sha2' +require 'net/http' +require 'rubygems/package' +require 'zlib' + +module Redmine + # Mermaid.js support for ```mermaid code blocks. + # + # mermaid.js is not bundled with Redmine. Administrators install it with + # `bin/rails redmine:mermaid:install`, which places dist/mermaid.min.js from + # the mermaid npm package into vendor/javascript, one of Propshaft's load + # paths. Diagram rendering is enabled whenever that file exists. The task + # installs mermaid.js VERSION and verifies its checksum. The file is not + # verified afterwards; if a different version is found there later, it is + # still used and a warning is logged. + module Mermaid + # Version installed by the redmine:mermaid:install task + VERSION = '12.0.0' + # SHA-256 of dist/mermaid.min.js in the mermaid npm package + SHA256 = '28fca7ae6ebc7ed7bb63bde63136a74bfef14f296a57e403657eeb8b32836073' + TARBALL_URL = "https://registry.npmjs.org/mermaid/-/mermaid-#{VERSION}.tgz" + TARBALL_ENTRY = 'package/dist/mermaid.min.js' + FILENAME = 'mermaid.min.js' + # Matches the version string that mermaid builds embed for mermaid's + # getVersion function, e.g. `a={version:"12.0.0"},b=i(()=>a.version,"getVersion")` + VERSION_REGEXP = /version:\s*"(\d+\.\d+\.\d+[\w.+-]*)"\s*\}[^"]{0,120}"getVersion"/ + + class Error < StandardError; end + + class << self + # Path of the installed file. Writable for tests. + attr_writer :path + + def path + @path ||= Rails.root.join('vendor/javascript', FILENAME) + end + + # Expected SHA-256 of the file to install. Stubbable for tests. + def checksum + SHA256 + end + + # Returns true if mermaid.js is installed. The result is cached until + # #reset is called. + def available? + return @available if defined?(@available) + + @available = path.file? + warn_version_mismatch if @available && installed_version != VERSION + @available + end + + # Returns the version of the installed mermaid.js as embedded in the + # file, or nil if mermaid.js is not installed or its version cannot be + # determined. The result is cached until #reset is called. + def installed_version + return @installed_version if defined?(@installed_version) + + @installed_version = + begin + path.file? ? File.binread(path)[VERSION_REGEXP, 1] : nil + rescue SystemCallError + nil + end + end + + def reset + remove_instance_variable(:@available) if defined?(@available) + remove_instance_variable(:@installed_version) if defined?(@installed_version) + end + + # Installs mermaid.min.js and returns its path. + # + # +source+ is a local path to either the mermaid npm tarball or + # mermaid.min.js itself. When nil, the tarball is downloaded from + # TARBALL_URL. Raises Redmine::Mermaid::Error on failure, including + # when the checksum of the file is not that of mermaid.js VERSION. + def install(source: nil) + content = source ? read_local(source) : download(TARBALL_URL) + verify!(content) + path.dirname.mkpath + # atomic_write creates a new file, so its mtime is newer than the + # asset manifest and the asset update detection at startup picks it up. + File.atomic_write(path) {|f| f.write(content)} + reset + path + end + + def uninstall + FileUtils.rm_f(path) + reset + end + + private + + def warn_version_mismatch + Rails.logger.warn( + "mermaid.js #{installed_version || '(unknown version)'} is installed at #{path}, " \ + "which is not the version installed by default (#{VERSION}). " \ + "Run 'bin/rails redmine:mermaid:install' to install mermaid.js #{VERSION}." + ) + end + + def verify!(content) + digest = Digest::SHA256.hexdigest(content) + return if digest == checksum + + raise Error, "checksum mismatch: expected #{checksum} (mermaid.js #{VERSION}), got #{digest}" + end + + def read_local(source) + data = File.binread(source) + gzip?(data) ? extract(data) : data + rescue SystemCallError => e + raise Error, "cannot read #{source}: #{e.message}" + end + + def gzip?(data) + data.byteslice(0, 2) == "\x1f\x8b".b + end + + def download(url, redirects_left = 5) + raise Error, "too many redirects while downloading #{url}" if redirects_left.zero? + + uri = URI.parse(url) + response = + Net::HTTP.start( + uri.host, uri.port, + use_ssl: uri.scheme == 'https', open_timeout: 15, read_timeout: 120 + ) do |http| + http.get(uri.request_uri) + end + case response + when Net::HTTPSuccess + extract(response.body) + when Net::HTTPRedirection + download(URI.join(url, response['location']).to_s, redirects_left - 1) + else + raise Error, "download of #{url} failed: #{response.code} #{response.message}" + end + rescue SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError => e + raise Error, "download of #{url} failed: #{e.message}" + end + + def extract(tarball) + Zlib::GzipReader.wrap(StringIO.new(tarball)) do |gz| + Gem::Package::TarReader.new(gz) do |tar| + tar.each do |entry| + return entry.read if entry.full_name == TARBALL_ENTRY + end + end + end + raise Error, "#{TARBALL_ENTRY} not found in the tarball" + rescue Zlib::Error, Gem::Package::TarInvalidError => e + raise Error, "cannot read the tarball: #{e.message}" + end + end + end +end diff --git a/lib/tasks/mermaid.rake b/lib/tasks/mermaid.rake new file mode 100644 index 000000000..4e4b13959 --- /dev/null +++ b/lib/tasks/mermaid.rake @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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. + +namespace :redmine do + namespace :mermaid do + desc <<~END_DESC + Installs mermaid.js so that ```mermaid code blocks are rendered as diagrams. + + mermaid.js is not bundled with Redmine. This task downloads the mermaid + npm package from registry.npmjs.org, verifies its checksum and places + dist/mermaid.min.js into vendor/javascript. + Set FILE to install from a local copy of the npm tarball or of + mermaid.min.js instead, e.g. on a server without internet access. + Restart Redmine after installing. + + Examples: + bin/rails redmine:mermaid:install RAILS_ENV="production" + bin/rails redmine:mermaid:install FILE=/path/to/mermaid.tgz RAILS_ENV="production" + END_DESC + task :install => :environment do + begin + path = Redmine::Mermaid.install(source: ENV['FILE'].presence) + rescue Redmine::Mermaid::Error => e + abort "mermaid.js installation failed: #{e.message}" + end + puts "mermaid.js #{Redmine::Mermaid::VERSION} installed to #{path}." + puts "Restart Redmine to enable diagram rendering. " \ + "If automatic asset compilation is disabled, run assets:precompile first." + end + + desc 'Removes the installed mermaid.js.' + task :uninstall => :environment do + Redmine::Mermaid.uninstall + puts "mermaid.js removed. Restart Redmine to apply the change." + end + + desc 'Shows whether mermaid.js is installed and its version.' + task :status => :environment do + if Redmine::Mermaid.available? + version = Redmine::Mermaid.installed_version + puts "mermaid.js #{version || '(unknown version)'} is installed at #{Redmine::Mermaid.path}." + if version != Redmine::Mermaid::VERSION + puts "The version installed by default is #{Redmine::Mermaid::VERSION}. " \ + "Run redmine:mermaid:install to install it." + end + else + puts "mermaid.js is not installed. Run redmine:mermaid:install to install it." + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 35b390953..f05fe3c86 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -187,6 +187,14 @@ class ActiveSupport::TestCase self.class.gs_installed? end + def self.mermaid_installed? + Redmine::Mermaid.available? + end + + def mermaid_installed? + self.class.mermaid_installed? + end + # Returns the path to the test +vendor+ repository def self.repository_path(vendor) path = Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s diff --git a/test/unit/lib/redmine/mermaid_test.rb b/test/unit/lib/redmine/mermaid_test.rb new file mode 100644 index 000000000..1db5de527 --- /dev/null +++ b/test/unit/lib/redmine/mermaid_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- 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::MermaidTest < ActiveSupport::TestCase + CONTENT = %(var a={version:"#{Redmine::Mermaid::VERSION}"},b=i(()=>a.version,"getVersion");globalThis.mermaid={};\n) + + def setup + @dir = Dir.mktmpdir + Redmine::Mermaid.path = Pathname.new(@dir).join('mermaid.min.js') + Redmine::Mermaid.reset + Redmine::Mermaid.stubs(:checksum).returns(Digest::SHA256.hexdigest(CONTENT)) + end + + def teardown + Redmine::Mermaid.path = nil + Redmine::Mermaid.reset + FileUtils.rm_rf(@dir) + end + + def test_available_should_be_false_when_not_installed + assert_not Redmine::Mermaid.available? + end + + def test_available_should_be_true_when_installed + File.write(Redmine::Mermaid.path, CONTENT) + assert Redmine::Mermaid.available? + end + + def test_available_should_log_a_warning_when_another_version_is_installed + File.write(Redmine::Mermaid.path, CONTENT.sub(Redmine::Mermaid::VERSION, '11.17.2')) + Rails.logger.expects(:warn).with(regexp_matches(/mermaid\.js 11\.17\.2 is installed/)) + + assert Redmine::Mermaid.available? + end + + def test_installed_version + File.write(Redmine::Mermaid.path, CONTENT) + assert_equal Redmine::Mermaid::VERSION, Redmine::Mermaid.installed_version + end + + def test_installed_version_should_be_nil_when_it_cannot_be_determined + assert_nil Redmine::Mermaid.installed_version + + File.write(Redmine::Mermaid.path, "globalThis.mermaid = {};\n") + Redmine::Mermaid.reset + assert_nil Redmine::Mermaid.installed_version + end + + def test_install_from_local_file + source = File.join(@dir, 'source.js') + File.write(source, CONTENT) + + path = Redmine::Mermaid.install(source: source) + + assert_equal Redmine::Mermaid.path, path + assert_equal CONTENT, File.read(path) + assert Redmine::Mermaid.available? + end + + def test_install_from_local_tarball + source = File.join(@dir, 'mermaid.tgz') + File.binwrite(source, tarball('package/dist/mermaid.min.js' => CONTENT, 'package/package.json' => '{}')) + + Redmine::Mermaid.install(source: source) + + assert_equal CONTENT, File.read(Redmine::Mermaid.path) + end + + def test_install_should_reject_content_with_wrong_checksum + source = File.join(@dir, 'source.js') + File.write(source, "console.log('another version');") + + error = assert_raises(Redmine::Mermaid::Error) {Redmine::Mermaid.install(source: source)} + assert_match /checksum mismatch/, error.message + assert_not File.exist?(Redmine::Mermaid.path) + end + + def test_install_should_download_the_tarball + Net::HTTP.expects(:start).once.returns(http_response(Net::HTTPOK, '200', body: tarball('package/dist/mermaid.min.js' => CONTENT))) + + Redmine::Mermaid.install + + assert_equal CONTENT, File.read(Redmine::Mermaid.path) + assert Redmine::Mermaid.available? + end + + def test_install_should_fail_when_download_fails + Net::HTTP.stubs(:start).returns(http_response(Net::HTTPNotFound, '404')) + + error = assert_raises(Redmine::Mermaid::Error) {Redmine::Mermaid.install} + assert_match /404/, error.message + assert_not File.exist?(Redmine::Mermaid.path) + end + + private + + def tarball(entries) + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + Gem::Package::TarWriter.new(gz) do |tar| + entries.each do |name, content| + tar.add_file_simple(name, 0o644, content.bytesize) {|f| f.write(content)} + end + end + gz.finish + io.string + end + + def http_response(klass, code, body: nil) + response = klass.new('1.1', code, klass.name.demodulize) + response.stubs(:body).returns(body) if body + response + end +end -- 2.55.0