Project

General

Profile

Feature #44425 » 0001-Add-a-rake-task-that-installs-mermaid.js.patch

Go MAEDA, 2026-10-02 08:09

View differences:

.gitignore
38 38
/tmp/test/*
39 39
/tmp/thumbnails/*
40 40
/vendor/cache
41
/vendor/javascript/mermaid.min.js
41 42
*.rbc
42 43
/.bundle
app/controllers/admin_controller.rb
85 85
      [:text_minimagick_available,     Object.const_defined?(:MiniMagick)],
86 86
      [:text_convert_available,        Redmine::Thumbnail.convert_available?],
87 87
      [:text_gs_available,             Redmine::Thumbnail.gs_available?],
88
      [:text_pandoc_available, Redmine::Markdownizer.available?]
88
      [:text_pandoc_available, Redmine::Markdownizer.available?],
89
      [:text_mermaid_available, Redmine::Mermaid.available?]
89 90
    ]
90 91
    @checklist << [:text_default_active_job_queue_changed, Rails.application.config.active_job.queue_adapter != :async] if Rails.env.production?
91 92
  end
config/locales/en.yml
1350 1350
  text_convert_available: ImageMagick convert available (optional)
1351 1351
  text_gs_available: ImageMagick PDF support available (optional)
1352 1352
  text_pandoc_available: Pandoc available (optional)
1353
  text_mermaid_available: Mermaid.js available (optional)
1353 1354
  text_default_active_job_queue_changed: Default queue adapter which is well suited only for dev/test changed
1354 1355
  text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?"
1355 1356
  text_destroy_time_entries: Delete reported hours
doc/INSTALL
80 80
   removing the public/assets directory before re-running the precompile:
81 81
     bin/rails assets:clobber RAILS_ENV="production"
82
7. Create the database structure
82
7. Install Mermaid.js (optional)
83

  
84
   Redmine can render ```mermaid code blocks in formatted text as diagrams
85
   with Mermaid.js (https://mermaid.js.org/). Mermaid.js is not bundled with
86
   Redmine. To install it, run:
87
     bin/rails redmine:mermaid:install RAILS_ENV="production"
88

  
89
   This downloads the mermaid package from registry.npmjs.org, verifies its
90
   checksum and places mermaid.min.js into the vendor/javascript directory.
91
   On a server without internet access, download the package on another
92
   machine and point FILE to it:
93
     bin/rails redmine:mermaid:install FILE=/path/to/mermaid.tgz RAILS_ENV="production"
94

  
95
   Restart the application after installing. If automatic asset compilation
96
   is disabled, run assets:precompile again as well. The availability of
97
   Mermaid.js is shown in "Administration -> Information", and it can be
98
   removed with:
99
     bin/rails redmine:mermaid:uninstall RAILS_ENV="production"
100

  
101
8. Create the database structure
83 102
   Under the application main directory run:
84 103
     bin/rails db:migrate RAILS_ENV="production"
85 104
   It will create all the tables and an administrator account.
86
8. Setting up permissions (Windows users have to skip this section)
105
9. Setting up permissions (Windows users have to skip this section)
87 106
   The user who runs Redmine must have write permission on the following
88 107
   subdirectories: files, log, tmp & public/assets.
......
96 115
     sudo chown -R redmine:redmine files log tmp public/assets
97 116
     sudo chmod -R 755 files log tmp public/assets
98
9. Test the installation by running the Puma web server
117
10. Test the installation by running the Puma web server
99 118
   Under the main application directory run:
100 119
     ruby bin/rails server -e production
......
104 123
   Once Puma has started, point your browser to http://localhost:3000/
105 124
   You should now see the application welcome page.
106
10. Use the default administrator account to log in:
125
11. Use the default administrator account to log in:
107 126
   login: admin
108 127
   password: admin
doc/UPGRADING
76 76
   removing the public/assets directory before re-running the precompile:
77 77
     bin/rails assets:clobber RAILS_ENV="production"
78
8. Migrate your database
78
8. Install or update Mermaid.js (optional)
79

  
80
   If you use Mermaid.js diagram rendering, run the following command again,
81
   as the version of Mermaid.js installed by default may change between
82
   Redmine releases:
83
     bin/rails redmine:mermaid:install RAILS_ENV="production"
84

  
85
   See doc/INSTALL for details.
86

  
87
9. Migrate your database
79 88
   Please make a backup before doing this! Under the new application
80 89
   directory run:
......
86 95
   migrations using:
87 96
     bin/rails redmine:plugins:migrate RAILS_ENV="production"
88
9. Clear the cache and the existing sessions by running:
97
10. Clear the cache and the existing sessions by running:
89 98
     bin/rails tmp:cache:clear tmp:sessions:clear
90
10. Restart the application server (e.g. mongrel, thin, passenger)
99
11. Restart the application server (e.g. mongrel, thin, passenger)
91
11. Finally go to "Administration -> Roles & permissions" to check/set permissions
100
12. Finally go to "Administration -> Roles & permissions" to check/set permissions
92 101
    for new features, if any
93 102
== References
lib/redmine/mermaid.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require 'digest/sha2'
21
require 'net/http'
22
require 'rubygems/package'
23
require 'zlib'
24

  
25
module Redmine
26
  # Mermaid.js support for ```mermaid code blocks.
27
  #
28
  # mermaid.js is not bundled with Redmine. Administrators install it with
29
  # `bin/rails redmine:mermaid:install`, which places dist/mermaid.min.js from
30
  # the mermaid npm package into vendor/javascript, one of Propshaft's load
31
  # paths. Diagram rendering is enabled whenever that file exists. The task
32
  # installs mermaid.js VERSION and verifies its checksum. The file is not
33
  # verified afterwards; if a different version is found there later, it is
34
  # still used and a warning is logged.
35
  module Mermaid
36
    # Version installed by the redmine:mermaid:install task
37
    VERSION = '12.0.0'
38
    # SHA-256 of dist/mermaid.min.js in the mermaid npm package
39
    SHA256 = '28fca7ae6ebc7ed7bb63bde63136a74bfef14f296a57e403657eeb8b32836073'
40
    TARBALL_URL = "https://registry.npmjs.org/mermaid/-/mermaid-#{VERSION}.tgz"
41
    TARBALL_ENTRY = 'package/dist/mermaid.min.js'
42
    FILENAME = 'mermaid.min.js'
43
    # Matches the version string that mermaid builds embed for mermaid's
44
    # getVersion function, e.g. `a={version:"12.0.0"},b=i(()=>a.version,"getVersion")`
45
    VERSION_REGEXP = /version:\s*"(\d+\.\d+\.\d+[\w.+-]*)"\s*\}[^"]{0,120}"getVersion"/
46

  
47
    class Error < StandardError; end
48

  
49
    class << self
50
      # Path of the installed file. Writable for tests.
51
      attr_writer :path
52

  
53
      def path
54
        @path ||= Rails.root.join('vendor/javascript', FILENAME)
55
      end
56

  
57
      # Expected SHA-256 of the file to install. Stubbable for tests.
58
      def checksum
59
        SHA256
60
      end
61

  
62
      # Returns true if mermaid.js is installed. The result is cached until
63
      # #reset is called.
64
      def available?
65
        return @available if defined?(@available)
66

  
67
        @available = path.file?
68
        warn_version_mismatch if @available && installed_version != VERSION
69
        @available
70
      end
71

  
72
      # Returns the version of the installed mermaid.js as embedded in the
73
      # file, or nil if mermaid.js is not installed or its version cannot be
74
      # determined. The result is cached until #reset is called.
75
      def installed_version
76
        return @installed_version if defined?(@installed_version)
77

  
78
        @installed_version =
79
          begin
80
            path.file? ? File.binread(path)[VERSION_REGEXP, 1] : nil
81
          rescue SystemCallError
82
            nil
83
          end
84
      end
85

  
86
      def reset
87
        remove_instance_variable(:@available) if defined?(@available)
88
        remove_instance_variable(:@installed_version) if defined?(@installed_version)
89
      end
90

  
91
      # Installs mermaid.min.js and returns its path.
92
      #
93
      # +source+ is a local path to either the mermaid npm tarball or
94
      # mermaid.min.js itself. When nil, the tarball is downloaded from
95
      # TARBALL_URL. Raises Redmine::Mermaid::Error on failure, including
96
      # when the checksum of the file is not that of mermaid.js VERSION.
97
      def install(source: nil)
98
        content = source ? read_local(source) : download(TARBALL_URL)
99
        verify!(content)
100
        path.dirname.mkpath
101
        # atomic_write creates a new file, so its mtime is newer than the
102
        # asset manifest and the asset update detection at startup picks it up.
103
        File.atomic_write(path) {|f| f.write(content)}
104
        reset
105
        path
106
      end
107

  
108
      def uninstall
109
        FileUtils.rm_f(path)
110
        reset
111
      end
112

  
113
      private
114

  
115
      def warn_version_mismatch
116
        Rails.logger.warn(
117
          "mermaid.js #{installed_version || '(unknown version)'} is installed at #{path}, " \
118
          "which is not the version installed by default (#{VERSION}). " \
119
          "Run 'bin/rails redmine:mermaid:install' to install mermaid.js #{VERSION}."
120
        )
121
      end
122

  
123
      def verify!(content)
124
        digest = Digest::SHA256.hexdigest(content)
125
        return if digest == checksum
126

  
127
        raise Error, "checksum mismatch: expected #{checksum} (mermaid.js #{VERSION}), got #{digest}"
128
      end
129

  
130
      def read_local(source)
131
        data = File.binread(source)
132
        gzip?(data) ? extract(data) : data
133
      rescue SystemCallError => e
134
        raise Error, "cannot read #{source}: #{e.message}"
135
      end
136

  
137
      def gzip?(data)
138
        data.byteslice(0, 2) == "\x1f\x8b".b
139
      end
140

  
141
      def download(url, redirects_left = 5)
142
        raise Error, "too many redirects while downloading #{url}" if redirects_left.zero?
143

  
144
        uri = URI.parse(url)
145
        response =
146
          Net::HTTP.start(
147
            uri.host, uri.port,
148
            use_ssl: uri.scheme == 'https', open_timeout: 15, read_timeout: 120
149
          ) do |http|
150
            http.get(uri.request_uri)
151
          end
152
        case response
153
        when Net::HTTPSuccess
154
          extract(response.body)
155
        when Net::HTTPRedirection
156
          download(URI.join(url, response['location']).to_s, redirects_left - 1)
157
        else
158
          raise Error, "download of #{url} failed: #{response.code} #{response.message}"
159
        end
160
      rescue SocketError, SystemCallError, IOError, Timeout::Error, OpenSSL::SSL::SSLError => e
161
        raise Error, "download of #{url} failed: #{e.message}"
162
      end
163

  
164
      def extract(tarball)
165
        Zlib::GzipReader.wrap(StringIO.new(tarball)) do |gz|
166
          Gem::Package::TarReader.new(gz) do |tar|
167
            tar.each do |entry|
168
              return entry.read if entry.full_name == TARBALL_ENTRY
169
            end
170
          end
171
        end
172
        raise Error, "#{TARBALL_ENTRY} not found in the tarball"
173
      rescue Zlib::Error, Gem::Package::TarInvalidError => e
174
        raise Error, "cannot read the tarball: #{e.message}"
175
      end
176
    end
177
  end
178
end
lib/tasks/mermaid.rake
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
namespace :redmine do
21
  namespace :mermaid do
22
    desc <<~END_DESC
23
      Installs mermaid.js so that ```mermaid code blocks are rendered as diagrams.
24

  
25
      mermaid.js is not bundled with Redmine. This task downloads the mermaid
26
      npm package from registry.npmjs.org, verifies its checksum and places
27
      dist/mermaid.min.js into vendor/javascript.
28
      Set FILE to install from a local copy of the npm tarball or of
29
      mermaid.min.js instead, e.g. on a server without internet access.
30
      Restart Redmine after installing.
31

  
32
      Examples:
33
        bin/rails redmine:mermaid:install RAILS_ENV="production"
34
        bin/rails redmine:mermaid:install FILE=/path/to/mermaid.tgz RAILS_ENV="production"
35
    END_DESC
36
    task :install => :environment do
37
      begin
38
        path = Redmine::Mermaid.install(source: ENV['FILE'].presence)
39
      rescue Redmine::Mermaid::Error => e
40
        abort "mermaid.js installation failed: #{e.message}"
41
      end
42
      puts "mermaid.js #{Redmine::Mermaid::VERSION} installed to #{path}."
43
      puts "Restart Redmine to enable diagram rendering. " \
44
           "If automatic asset compilation is disabled, run assets:precompile first."
45
    end
46

  
47
    desc 'Removes the installed mermaid.js.'
48
    task :uninstall => :environment do
49
      Redmine::Mermaid.uninstall
50
      puts "mermaid.js removed. Restart Redmine to apply the change."
51
    end
52

  
53
    desc 'Shows whether mermaid.js is installed and its version.'
54
    task :status => :environment do
55
      if Redmine::Mermaid.available?
56
        version = Redmine::Mermaid.installed_version
57
        puts "mermaid.js #{version || '(unknown version)'} is installed at #{Redmine::Mermaid.path}."
58
        if version != Redmine::Mermaid::VERSION
59
          puts "The version installed by default is #{Redmine::Mermaid::VERSION}. " \
60
               "Run redmine:mermaid:install to install it."
61
        end
62
      else
63
        puts "mermaid.js is not installed. Run redmine:mermaid:install to install it."
64
      end
65
    end
66
  end
67
end
test/test_helper.rb
187 187
    self.class.gs_installed?
188 188
  end
189
  def self.mermaid_installed?
190
    Redmine::Mermaid.available?
191
  end
192

  
193
  def mermaid_installed?
194
    self.class.mermaid_installed?
195
  end
196

  
189 197
  # Returns the path to the test +vendor+ repository
190 198
  def self.repository_path(vendor)
191 199
    path = Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
test/unit/lib/redmine/mermaid_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../test_helper'
21

  
22
class Redmine::MermaidTest < ActiveSupport::TestCase
23
  CONTENT = %(var a={version:"#{Redmine::Mermaid::VERSION}"},b=i(()=>a.version,"getVersion");globalThis.mermaid={};\n)
24

  
25
  def setup
26
    @dir = Dir.mktmpdir
27
    Redmine::Mermaid.path = Pathname.new(@dir).join('mermaid.min.js')
28
    Redmine::Mermaid.reset
29
    Redmine::Mermaid.stubs(:checksum).returns(Digest::SHA256.hexdigest(CONTENT))
30
  end
31

  
32
  def teardown
33
    Redmine::Mermaid.path = nil
34
    Redmine::Mermaid.reset
35
    FileUtils.rm_rf(@dir)
36
  end
37

  
38
  def test_available_should_be_false_when_not_installed
39
    assert_not Redmine::Mermaid.available?
40
  end
41

  
42
  def test_available_should_be_true_when_installed
43
    File.write(Redmine::Mermaid.path, CONTENT)
44
    assert Redmine::Mermaid.available?
45
  end
46

  
47
  def test_available_should_log_a_warning_when_another_version_is_installed
48
    File.write(Redmine::Mermaid.path, CONTENT.sub(Redmine::Mermaid::VERSION, '11.17.2'))
49
    Rails.logger.expects(:warn).with(regexp_matches(/mermaid\.js 11\.17\.2 is installed/))
50

  
51
    assert Redmine::Mermaid.available?
52
  end
53

  
54
  def test_installed_version
55
    File.write(Redmine::Mermaid.path, CONTENT)
56
    assert_equal Redmine::Mermaid::VERSION, Redmine::Mermaid.installed_version
57
  end
58

  
59
  def test_installed_version_should_be_nil_when_it_cannot_be_determined
60
    assert_nil Redmine::Mermaid.installed_version
61

  
62
    File.write(Redmine::Mermaid.path, "globalThis.mermaid = {};\n")
63
    Redmine::Mermaid.reset
64
    assert_nil Redmine::Mermaid.installed_version
65
  end
66

  
67
  def test_install_from_local_file
68
    source = File.join(@dir, 'source.js')
69
    File.write(source, CONTENT)
70

  
71
    path = Redmine::Mermaid.install(source: source)
72

  
73
    assert_equal Redmine::Mermaid.path, path
74
    assert_equal CONTENT, File.read(path)
75
    assert Redmine::Mermaid.available?
76
  end
77

  
78
  def test_install_from_local_tarball
79
    source = File.join(@dir, 'mermaid.tgz')
80
    File.binwrite(source, tarball('package/dist/mermaid.min.js' => CONTENT, 'package/package.json' => '{}'))
81

  
82
    Redmine::Mermaid.install(source: source)
83

  
84
    assert_equal CONTENT, File.read(Redmine::Mermaid.path)
85
  end
86

  
87
  def test_install_should_reject_content_with_wrong_checksum
88
    source = File.join(@dir, 'source.js')
89
    File.write(source, "console.log('another version');")
90

  
91
    error = assert_raises(Redmine::Mermaid::Error) {Redmine::Mermaid.install(source: source)}
92
    assert_match /checksum mismatch/, error.message
93
    assert_not File.exist?(Redmine::Mermaid.path)
94
  end
95

  
96
  def test_install_should_download_the_tarball
97
    Net::HTTP.expects(:start).once.returns(http_response(Net::HTTPOK, '200', body: tarball('package/dist/mermaid.min.js' => CONTENT)))
98

  
99
    Redmine::Mermaid.install
100

  
101
    assert_equal CONTENT, File.read(Redmine::Mermaid.path)
102
    assert Redmine::Mermaid.available?
103
  end
104

  
105
  def test_install_should_fail_when_download_fails
106
    Net::HTTP.stubs(:start).returns(http_response(Net::HTTPNotFound, '404'))
107

  
108
    error = assert_raises(Redmine::Mermaid::Error) {Redmine::Mermaid.install}
109
    assert_match /404/, error.message
110
    assert_not File.exist?(Redmine::Mermaid.path)
111
  end
112

  
113
  private
114

  
115
  def tarball(entries)
116
    io = StringIO.new
117
    gz = Zlib::GzipWriter.new(io)
118
    Gem::Package::TarWriter.new(gz) do |tar|
119
      entries.each do |name, content|
120
        tar.add_file_simple(name, 0o644, content.bytesize) {|f| f.write(content)}
121
      end
122
    end
123
    gz.finish
124
    io.string
125
  end
126

  
127
  def http_response(klass, code, body: nil)
128
    response = klass.new('1.1', code, klass.name.demodulize)
129
    response.stubs(:body).returns(body) if body
130
    response
131
  end
132
end
(5-5/5)