Project

General

Profile

Defect #44358 » 0001c-Store-images-of-markdownized-previews-as-attachments.patch

Florian Walchshofer, 2026-11-01 17:31

View differences:

app/models/attachment.rb
19 19

  
20 20
require "digest"
21 21
require "fileutils"
22
require "tmpdir"
22 23
require "zip"
23 24

  
24 25
class Attachment < ApplicationRecord
25 26
  include Redmine::SafeAttributes
26 27
  belongs_to :container, :polymorphic => true
27 28
  belongs_to :author, :class_name => "User"
29
  MARKDOWNIZED_PREVIEW_IMAGE_PREFIX = 'markdownized_preview_image_'
30
  # Other attachments of this attachment are not images of the preview.
31
  scope :named_as_markdownized_preview_image, lambda {
32
    where("#{table_name}.filename LIKE ?", "#{sanitize_sql_like(MARKDOWNIZED_PREVIEW_IMAGE_PREFIX)}%")
33
  }
34
  has_many :markdownized_preview_images, lambda {named_as_markdownized_preview_image.order(:id)},
35
           :class_name => 'Attachment', :as => :container, :dependent => :destroy, :inverse_of => :container
28 36

  
29 37
  validates_presence_of :filename, :author
30 38
  validates_length_of :filename, :maximum => 255
......
92 100
    File.join(storage_path, 'derived_cache', 'markdownized_previews')
93 101
  end
94 102

  
103
  MARKDOWNIZED_PREVIEW_IMAGE_REFERENCE = /(?<=src="|\]\()#{MARKDOWNIZED_PREVIEW_IMAGE_PREFIX}\d+\.[a-z]+\b/
104

  
95 105
  before_create :set_content_type, :files_to_final_location
96 106
  before_update :reset_content_type, :if => :filename_changed?
97 107
  after_commit :delete_from_disk, :on => :destroy
......
247 257
    end
248 258
  end
249 259

  
260
  # The images of a markdownized preview are visible with the previewed
261
  # attachment. They are generated, so they can't be changed separately.
262
  def attachments_visible?(user=User.current)
263
    visible?(user)
264
  end
265

  
266
  def attachments_editable?(user=User.current)
267
    false
268
  end
269

  
270
  def attachments_deletable?(user=User.current)
271
    false
272
  end
273

  
250 274
  def image?
251 275
    !!(self.filename =~ /\.(avif|bmp|gif|jpg|jpe|jpeg|png|webp)$/i)
252 276
  end
......
303 327
    Dir.glob(File.join(markdownized_previews_storage_path, "*.md")).each do |file|
304 328
      File.delete file
305 329
    end
330
    where(:container_type => name).named_as_markdownized_preview_image.find_each(&:destroy)
306 331
  end
307 332

  
308 333
  def is_text?
......
358 383
    return nil unless markdownized_previewable?
359 384

  
360 385
    target = markdownized_preview_cache_path
361
    if Redmine::Markdownizer.convert(diskfile, target)
362
      File.read(target, :mode => "rb")
363
    end
386
    content = File.read(target, :mode => "rb") if File.exist?(target)
387
    return content if content && markdownized_preview_images_complete?(content)
388

  
389
    generate_markdownized_preview(target)
364 390
  rescue => e
365 391
    if logger
366 392
      logger.error(
......
600 626
    # anymore, thats why this is caught and ignored as well.
601 627
  end
602 628

  
629
  # The cached preview is shared by the attachments with the same file, but
630
  # each of them has its own images: another one may have created the preview
631
  def markdownized_preview_images_complete?(content)
632
    filenames = content.scan(MARKDOWNIZED_PREVIEW_IMAGE_REFERENCE).uniq
633
    (filenames - markdownized_preview_images.map(&:filename)).empty?
634
  end
635

  
636
  def generate_markdownized_preview(target)
637
    Dir.mktmpdir('markdownized-preview') do |directory|
638
      next unless Redmine::Markdownizer.convert(diskfile, target, directory)
639

  
640
      content = File.read(target, :mode => "rb")
641
      # The lock is not held during the conversion, which can take a long time.
642
      # Another request may have stored the images in the meantime: they are
643
      # kept because pages may already display them.
644
      with_lock do
645
        next if markdownized_preview_images_complete?(content)
646

  
647
        markdownized_preview_images.destroy_all
648
        Dir.children(directory).sort.each do |filename|
649
          File.open(File.join(directory, filename), 'rb') do |file|
650
            # Validations are skipped because these are not uploaded files:
651
            # the size and extension limits are not meant for them
652
            image = markdownized_preview_images.build(:file => file, :filename => filename, :author => author)
653
            image.save(:validate => false)
654
          end
655
        end
656
      end
657
      content
658
    end
659
  end
660

  
603 661
  # Physically deletes the file from the file system
604 662
  def delete_from_disk!
605 663
    FileUtils.rm_f(diskfile) if disk_filename.present?
app/views/attachments/markdownized.html.erb
1 1
<%= render :layout => 'layouts/file' do %>
2 2
  <%= render :partial => 'common/markup',
3
             :locals => {:markup_text_formatting => 'common_mark', :markup_text => @content} %>
3
             :locals => {:markup_text_formatting => 'common_mark', :markup_text => @content,
4
                         :attachments => @attachment.markdownized_preview_images.to_a} %>
4 5
<% end %>
app/views/common/_markup.html.erb
1 1
<div class="filecontent wiki">
2
  <%= Redmine::WikiFormatting.to_html(markup_text_formatting, Redmine::CodesetUtil.to_utf8_by_setting(markup_text)).html_safe %>
2
  <%= Redmine::WikiFormatting.to_html(markup_text_formatting, Redmine::CodesetUtil.to_utf8_by_setting(markup_text), :attachments => local_assigns[:attachments], :view => self, :only_path => true).html_safe %>
3 3
</div>
extra/pandoc_filter/markdownized_preview_images.lua
1
-- Writes the images embedded in the document to the current directory, with
2
-- names that do not depend on the document. Images that are not embedded in
3
-- the document are never fetched, and formats that browsers cannot display
4
-- are replaced with their description.
5
local extensions = {
6
  ["image/avif"] = "avif",
7
  ["image/bmp"] = "bmp",
8
  ["image/gif"] = "gif",
9
  ["image/jpeg"] = "jpg",
10
  ["image/png"] = "png",
11
  ["image/webp"] = "webp"
12
}
13
local filenames = {}
14
local count = 0
15

  
16
function Image(el)
17
  local mime, contents = pandoc.mediabag.lookup(el.src)
18
  local extension = extensions[mime]
19

  
20
  if not extension then
21
    local description = pandoc.utils.stringify(el.caption)
22
    return pandoc.Str("[" .. (description ~= "" and description or el.src:match("[^/]*$")) .. "]")
23
  end
24

  
25
  if not filenames[el.src] then
26
    count = count + 1
27
    filenames[el.src] = "markdownized_preview_image_" .. count .. "." .. extension
28
    local file = assert(io.open(filenames[el.src], "wb"))
29
    file:write(contents)
30
    file:close()
31
  end
32

  
33
  el.src = filenames[el.src]
34
  return el
35
end
lib/redmine/markdownizer.rb
30 30
    PREVIEW_GENERATION_TIMEOUT = Redmine::Configuration['markdownized_preview_generation_timeout'].to_i
31 31
    MAX_SOURCE_SIZE = Redmine::Configuration['markdownized_preview_max_source_size'].to_i
32 32
    MAX_OUTPUT_SIZE = Redmine::Configuration['markdownized_preview_max_output_size'].to_i
33
    IMAGES_FILTER = Rails.root.join('extra', 'pandoc_filter', 'markdownized_preview_images.lua').to_s.freeze
33 34

  
34 35
    def self.supports?(filename)
35 36
      markdownizable_extensions.include?(File.extname(filename.to_s).downcase)
36 37
    end
37 38

  
38
    def self.convert(source, target)
39
    def self.convert(source, target, media_directory)
39 40
      return nil unless available?
40
      return target if File.exist?(target)
41 41

  
42 42
      if File.size(source) > MAX_SOURCE_SIZE
43 43
        logger.warn("Markdownized preview generation skipped because source file is too large (#{File.size(source)} bytes): #{source}")
......
46 46

  
47 47
      directory = File.dirname(target)
48 48
      FileUtils.mkdir_p(directory)
49
      args = [COMMAND, source, "-t", "gfm"]
49
      args = [COMMAND, File.expand_path(source), "-t", "gfm", *image_options]
50 50
      pid = nil
51 51
      output = Tempfile.new('markdownized-preview')
52 52

  
53 53
      begin
54 54
        Timeout.timeout(PREVIEW_GENERATION_TIMEOUT) do
55
          pid = Process.spawn(*args, out: output.path)
55
          pid = Process.spawn(*args, chdir: media_directory, out: output.path)
56 56
          _, status = Process.wait2(pid)
57 57
          unless status.success?
58 58
            logger.error("Markdownized preview generation failed (#{status.exitstatus}):\nCommand: #{args.shelljoin}")
......
80 80
      output&.unlink
81 81
    end
82 82

  
83
    def self.images_available?
84
      available? && (@pandoc_version <=> [2, 15]) >= 0
85
    end
86

  
87
    def self.image_options
88
      images_available? ? ["--sandbox", "--quiet", "--lua-filter=#{IMAGES_FILTER}"] : []
89
    end
90

  
83 91
    def self.available?
84 92
      return @available if defined?(@available)
85 93

  
test/functional/attachments_controller_test.rb
316 316
    assert_select '.nodata', :count => 0
317 317
  end
318 318

  
319
  def test_show_msword_should_display_images_of_the_document
320
    skip unless Redmine::Markdownizer.images_available?
321

  
322
    set_tmp_attachments_directory
323
    a = Attachment.create!(
324
      :container => Issue.find(1),
325
      :file => uploaded_test_file(
326
        'msword.docx',
327
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
328
      ),
329
      :author => User.find(1)
330
    )
331

  
332
    get(:show, :params => {:id => a.id})
333

  
334
    assert_response :success
335
    image = a.markdownized_preview_images.first
336
    assert_select "div.filecontent.wiki img[src=?]", download_named_attachment_path(image, image.filename)
337

  
338
    get(:download, :params => {:id => image.id})
339
    assert_response :success
340
    assert_equal 'image/png', @response.media_type
341
  end
342

  
343
  def test_download_of_markdownized_preview_image_should_require_access_to_the_attachment
344
    skip unless Redmine::Markdownizer.images_available?
345

  
346
    set_tmp_attachments_directory
347
    a = Attachment.create!(
348
      :container => Issue.find(4),
349
      :file => uploaded_test_file(
350
        'msword.docx',
351
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
352
      ),
353
      :author => User.find(1)
354
    )
355
    a.markdownized_preview_content
356
    image = a.markdownized_preview_images.first
357

  
358
    @request.session[:user_id] = nil
359
    get(:download, :params => {:id => image.id})
360
    assert_response :found
361

  
362
    @request.session[:user_id] = 2
363
    get(:download, :params => {:id => image.id})
364
    assert_response :success
365
  end
366

  
319 367
  def test_show_libreoffice_writer
320 368
    skip unless Redmine::Markdownizer.available?
321 369

  
test/unit/attachment_test.rb
692 692
    assert_not File.exist?(preview)
693 693
  end
694 694

  
695
  def docx_attachment
696
    Attachment.create!(
697
      :container => Issue.find(1),
698
      :file => uploaded_test_file(
699
        "msword.docx",
700
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
701
      ),
702
      :author => User.find(1)
703
    )
704
  end
705
  private :docx_attachment
706

  
707
  def test_markdownized_preview_content_should_store_images_as_attachments
708
    skip unless Redmine::Markdownizer.images_available?
709

  
710
    attachment = docx_attachment
711
    content = attachment.markdownized_preview_content
712

  
713
    assert_include 'src="markdownized_preview_image_1.png"', content
714
    assert_equal attachment.markdownized_preview_cache_path,
715
                 File.join(Attachment.markdownized_previews_storage_path, "#{attachment.digest}_#{attachment.filesize}.md")
716
    assert File.exist?(attachment.markdownized_preview_cache_path)
717
    image = attachment.reload.markdownized_preview_images.first
718
    assert_equal 'markdownized_preview_image_1.png', image.filename
719
    assert_equal 'image/png', image.content_type
720
    assert_equal attachment, image.container
721
    assert_equal attachment.project, image.project
722
    assert image.readable?
723
  end
724

  
725
  def test_markdownized_preview_content_should_use_the_cache
726
    skip unless Redmine::Markdownizer.images_available?
727

  
728
    attachment = docx_attachment
729
    content = attachment.markdownized_preview_content
730

  
731
    Redmine::Markdownizer.expects(:convert).never
732
    assert_equal content, attachment.reload.markdownized_preview_content
733
    assert_equal 1, attachment.markdownized_preview_images.count
734
  end
735

  
736
  def test_markdownized_preview_content_should_ignore_image_filenames_in_the_text
737
    attachment = docx_attachment
738
    FileUtils.mkdir_p(Attachment.markdownized_previews_storage_path)
739
    File.binwrite(attachment.markdownized_preview_cache_path, 'The image is markdownized_preview_image_7.png')
740

  
741
    Redmine::Markdownizer.expects(:convert).never
742
    assert_include 'The image is', attachment.markdownized_preview_content
743
  end
744

  
745
  def test_markdownized_preview_content_should_generate_missing_images
746
    attachment = docx_attachment
747
    FileUtils.mkdir_p(Attachment.markdownized_previews_storage_path)
748
    Redmine::Markdownizer.expects(:convert).twice.returns(nil)
749

  
750
    ['![](markdownized_preview_image_1.png "Picture 1")', '<img src="markdownized_preview_image_1.jpg" />'].each do |content|
751
      File.binwrite(attachment.markdownized_preview_cache_path, content)
752
      assert_nil attachment.markdownized_preview_content
753
    end
754
  end
755

  
756
  def test_markdownized_preview_content_should_keep_images_stored_by_a_concurrent_request
757
    skip unless Redmine::Markdownizer.images_available?
758

  
759
    attachment = docx_attachment
760
    attachment.markdownized_preview_content
761
    image = attachment.reload.markdownized_preview_images.first
762

  
763
    # The other request found no cached preview before this one stored it
764
    Attachment.find(attachment.id).send(:generate_markdownized_preview, attachment.markdownized_preview_cache_path)
765
    assert_equal [image], attachment.reload.markdownized_preview_images.to_a
766
  end
767

  
768
  def test_markdownized_preview_content_should_not_convert_in_a_transaction
769
    skip unless Redmine::Markdownizer.available?
770

  
771
    attachment = docx_attachment
772
    transactions = Attachment.lease_connection.open_transactions
773
    transactions_during_conversion = nil
774
    Redmine::Markdownizer.expects(:convert).with do
775
      transactions_during_conversion = Attachment.lease_connection.open_transactions
776
      true
777
    end.returns(nil)
778

  
779
    assert_nil attachment.markdownized_preview_content
780
    assert_equal transactions, transactions_during_conversion
781
  end
782

  
783
  def test_markdownized_preview_content_should_store_images_for_each_attachment_with_the_same_file
784
    skip unless Redmine::Markdownizer.images_available?
785

  
786
    attachment = docx_attachment
787
    attachment.markdownized_preview_content
788
    copy = attachment.copy(:container => Issue.find(2))
789
    copy.save!
790
    assert File.exist?(copy.markdownized_preview_cache_path)
791

  
792
    assert_include 'markdownized_preview_image_1.png', copy.markdownized_preview_content
793
    image = copy.reload.markdownized_preview_images.first
794
    assert_equal 'markdownized_preview_image_1.png', image.filename
795

  
796
    attachment.destroy
797
    assert image.reload.readable?
798
  end
799

  
800
  def test_markdownizer_should_export_images_only_with_the_sandbox
801
    skip unless Redmine::Markdownizer.available?
802

  
803
    version = Redmine::Markdownizer.instance_variable_get(:@pandoc_version)
804
    begin
805
      Redmine::Markdownizer.instance_variable_set(:@pandoc_version, [2, 14, 2])
806
      assert_not Redmine::Markdownizer.images_available?
807
      assert_equal [], Redmine::Markdownizer.image_options
808
      Redmine::Markdownizer.instance_variable_set(:@pandoc_version, [2, 15])
809
      assert Redmine::Markdownizer.images_available?
810
      assert_equal ['--sandbox', '--quiet', "--lua-filter=#{Redmine::Markdownizer::IMAGES_FILTER}"], Redmine::Markdownizer.image_options
811
    ensure
812
      Redmine::Markdownizer.instance_variable_set(:@pandoc_version, version)
813
    end
814
  end
815

  
816
  def test_destroy_should_destroy_markdownized_preview_images
817
    skip unless Redmine::Markdownizer.images_available?
818

  
819
    attachment = docx_attachment
820
    attachment.markdownized_preview_content
821
    image = attachment.reload.markdownized_preview_images.first
822
    diskfile = image.diskfile
823

  
824
    assert_difference 'Attachment.count', -2 do
825
      attachment.destroy
826
    end
827
    assert_not File.exist?(diskfile)
828
  end
829

  
830
  def test_clear_markdownized_previews_should_destroy_only_preview_images
831
    skip unless Redmine::Markdownizer.images_available?
832

  
833
    attachment = docx_attachment
834
    attachment.markdownized_preview_content
835
    image = attachment.reload.markdownized_preview_images.first
836
    other = Attachment.create!(
837
      :container => attachment,
838
      :file => uploaded_test_file("testfile.txt", "text/plain"),
839
      :filename => 'other.txt',
840
      :author => User.find(1)
841
    )
842

  
843
    Attachment.clear_markdownized_previews
844

  
845
    assert_not File.exist?(attachment.markdownized_preview_cache_path)
846
    assert_not Attachment.exists?(image.id)
847
    assert Attachment.exists?(attachment.id)
848
    assert Attachment.exists?(other.id)
849

  
850
    assert_include 'markdownized_preview_image_1.png', attachment.reload.markdownized_preview_content
851
    assert_equal ['markdownized_preview_image_1.png'], attachment.reload.markdownized_preview_images.map(&:filename)
852
    assert Attachment.exists?(other.id)
853
  end
854

  
855
  def test_markdownized_preview_images_should_have_the_permissions_of_the_attachment
856
    skip unless Redmine::Markdownizer.images_available?
857

  
858
    attachment = docx_attachment
859
    attachment.markdownized_preview_content
860
    image = attachment.reload.markdownized_preview_images.first
861

  
862
    assert image.visible?(User.find(2))
863
    assert_equal attachment.visible?(User.anonymous), image.visible?(User.anonymous)
864
    assert_not image.editable?(User.find(1))
865
    assert_not image.deletable?(User.find(1))
866

  
867
    Issue.find(1).update_column(:is_private, true)
868
    attachment.reload.container.reload
869
    assert_not image.reload.visible?(User.find(7))
870
  end
871

  
872
  def test_markdownized_preview_content_should_not_read_files_referenced_by_a_document
873
    skip unless Redmine::Markdownizer.images_available?
874

  
875
    attachment = Attachment.create!(
876
      :container => Issue.find(1),
877
      :file => uploaded_test_file(
878
        "libreoffice-writer-linked-image.odt",
879
        "application/vnd.oasis.opendocument.text"
880
      ),
881
      :author => User.find(1)
882
    )
883

  
884
    content = attachment.markdownized_preview_content
885
    assert_include 'hostname', content
886
    assert_not_include 'src=', content
887
    assert_equal 0, attachment.reload.markdownized_preview_images.count
888
  end
889

  
695 890
  if convert_installed?
696 891
    def test_thumbnail_should_generate_the_thumbnail
697 892
      set_fixtures_attachments_directory
(9-9/10)