diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 6b00ab3b2..4bea52fca 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -19,12 +19,20 @@ require "digest" require "fileutils" +require "tmpdir" require "zip" class Attachment < ApplicationRecord include Redmine::SafeAttributes belongs_to :container, :polymorphic => true belongs_to :author, :class_name => "User" + MARKDOWNIZED_PREVIEW_IMAGE_PREFIX = 'markdownized_preview_image_' + # Other attachments of this attachment are not images of the preview. + scope :named_as_markdownized_preview_image, lambda { + where("#{table_name}.filename LIKE ?", "#{sanitize_sql_like(MARKDOWNIZED_PREVIEW_IMAGE_PREFIX)}%") + } + has_many :markdownized_preview_images, lambda {named_as_markdownized_preview_image.order(:id)}, + :class_name => 'Attachment', :as => :container, :dependent => :destroy, :inverse_of => :container validates_presence_of :filename, :author validates_length_of :filename, :maximum => 255 @@ -92,6 +100,8 @@ class Attachment < ApplicationRecord File.join(storage_path, 'derived_cache', 'markdownized_previews') end + MARKDOWNIZED_PREVIEW_IMAGE_REFERENCE = /(?<=src="|\]\()#{MARKDOWNIZED_PREVIEW_IMAGE_PREFIX}\d+\.[a-z]+\b/ + before_create :set_content_type, :files_to_final_location before_update :reset_content_type, :if => :filename_changed? after_commit :delete_from_disk, :on => :destroy @@ -247,6 +257,20 @@ class Attachment < ApplicationRecord end end + # The images of a markdownized preview are visible with the previewed + # attachment. They are generated, so they can't be changed separately. + def attachments_visible?(user=User.current) + visible?(user) + end + + def attachments_editable?(user=User.current) + false + end + + def attachments_deletable?(user=User.current) + false + end + def image? !!(self.filename =~ /\.(avif|bmp|gif|jpg|jpe|jpeg|png|webp)$/i) end @@ -303,6 +327,7 @@ class Attachment < ApplicationRecord Dir.glob(File.join(markdownized_previews_storage_path, "*.md")).each do |file| File.delete file end + where(:container_type => name).named_as_markdownized_preview_image.find_each(&:destroy) end def is_text? @@ -358,9 +383,10 @@ class Attachment < ApplicationRecord return nil unless markdownized_previewable? target = markdownized_preview_cache_path - if Redmine::Markdownizer.convert(diskfile, target) - File.read(target, :mode => "rb") - end + content = File.read(target, :mode => "rb") if File.exist?(target) + return content if content && markdownized_preview_images_complete?(content) + + generate_markdownized_preview(target) rescue => e if logger logger.error( @@ -600,6 +626,38 @@ class Attachment < ApplicationRecord # anymore, thats why this is caught and ignored as well. end + # The cached preview is shared by the attachments with the same file, but + # each of them has its own images: another one may have created the preview + def markdownized_preview_images_complete?(content) + filenames = content.scan(MARKDOWNIZED_PREVIEW_IMAGE_REFERENCE).uniq + (filenames - markdownized_preview_images.map(&:filename)).empty? + end + + def generate_markdownized_preview(target) + Dir.mktmpdir('markdownized-preview') do |directory| + next unless Redmine::Markdownizer.convert(diskfile, target, directory) + + content = File.read(target, :mode => "rb") + # The lock is not held during the conversion, which can take a long time. + # Another request may have stored the images in the meantime: they are + # kept because pages may already display them. + with_lock do + next if markdownized_preview_images_complete?(content) + + markdownized_preview_images.destroy_all + Dir.children(directory).sort.each do |filename| + File.open(File.join(directory, filename), 'rb') do |file| + # Validations are skipped because these are not uploaded files: + # the size and extension limits are not meant for them + image = markdownized_preview_images.build(:file => file, :filename => filename, :author => author) + image.save(:validate => false) + end + end + end + content + end + end + # Physically deletes the file from the file system def delete_from_disk! FileUtils.rm_f(diskfile) if disk_filename.present? diff --git a/app/views/attachments/markdownized.html.erb b/app/views/attachments/markdownized.html.erb index 3a158e827..ca211f13c 100644 --- a/app/views/attachments/markdownized.html.erb +++ b/app/views/attachments/markdownized.html.erb @@ -1,4 +1,5 @@ <%= render :layout => 'layouts/file' do %> <%= render :partial => 'common/markup', - :locals => {:markup_text_formatting => 'common_mark', :markup_text => @content} %> + :locals => {:markup_text_formatting => 'common_mark', :markup_text => @content, + :attachments => @attachment.markdownized_preview_images.to_a} %> <% end %> diff --git a/app/views/common/_markup.html.erb b/app/views/common/_markup.html.erb index 14786a87c..71507b7c7 100644 --- a/app/views/common/_markup.html.erb +++ b/app/views/common/_markup.html.erb @@ -1,3 +1,3 @@
'].each do |content|
+ File.binwrite(attachment.markdownized_preview_cache_path, content)
+ assert_nil attachment.markdownized_preview_content
+ end
+ end
+
+ def test_markdownized_preview_content_should_keep_images_stored_by_a_concurrent_request
+ skip unless Redmine::Markdownizer.images_available?
+
+ attachment = docx_attachment
+ attachment.markdownized_preview_content
+ image = attachment.reload.markdownized_preview_images.first
+
+ # The other request found no cached preview before this one stored it
+ Attachment.find(attachment.id).send(:generate_markdownized_preview, attachment.markdownized_preview_cache_path)
+ assert_equal [image], attachment.reload.markdownized_preview_images.to_a
+ end
+
+ def test_markdownized_preview_content_should_not_convert_in_a_transaction
+ skip unless Redmine::Markdownizer.available?
+
+ attachment = docx_attachment
+ transactions = Attachment.lease_connection.open_transactions
+ transactions_during_conversion = nil
+ Redmine::Markdownizer.expects(:convert).with do
+ transactions_during_conversion = Attachment.lease_connection.open_transactions
+ true
+ end.returns(nil)
+
+ assert_nil attachment.markdownized_preview_content
+ assert_equal transactions, transactions_during_conversion
+ end
+
+ def test_markdownized_preview_content_should_store_images_for_each_attachment_with_the_same_file
+ skip unless Redmine::Markdownizer.images_available?
+
+ attachment = docx_attachment
+ attachment.markdownized_preview_content
+ copy = attachment.copy(:container => Issue.find(2))
+ copy.save!
+ assert File.exist?(copy.markdownized_preview_cache_path)
+
+ assert_include 'markdownized_preview_image_1.png', copy.markdownized_preview_content
+ image = copy.reload.markdownized_preview_images.first
+ assert_equal 'markdownized_preview_image_1.png', image.filename
+
+ attachment.destroy
+ assert image.reload.readable?
+ end
+
+ def test_markdownizer_should_export_images_only_with_the_sandbox
+ skip unless Redmine::Markdownizer.available?
+
+ version = Redmine::Markdownizer.instance_variable_get(:@pandoc_version)
+ begin
+ Redmine::Markdownizer.instance_variable_set(:@pandoc_version, [2, 14, 2])
+ assert_not Redmine::Markdownizer.images_available?
+ assert_equal [], Redmine::Markdownizer.image_options
+ Redmine::Markdownizer.instance_variable_set(:@pandoc_version, [2, 15])
+ assert Redmine::Markdownizer.images_available?
+ assert_equal ['--sandbox', '--quiet', "--lua-filter=#{Redmine::Markdownizer::IMAGES_FILTER}"], Redmine::Markdownizer.image_options
+ ensure
+ Redmine::Markdownizer.instance_variable_set(:@pandoc_version, version)
+ end
+ end
+
+ def test_destroy_should_destroy_markdownized_preview_images
+ skip unless Redmine::Markdownizer.images_available?
+
+ attachment = docx_attachment
+ attachment.markdownized_preview_content
+ image = attachment.reload.markdownized_preview_images.first
+ diskfile = image.diskfile
+
+ assert_difference 'Attachment.count', -2 do
+ attachment.destroy
+ end
+ assert_not File.exist?(diskfile)
+ end
+
+ def test_clear_markdownized_previews_should_destroy_only_preview_images
+ skip unless Redmine::Markdownizer.images_available?
+
+ attachment = docx_attachment
+ attachment.markdownized_preview_content
+ image = attachment.reload.markdownized_preview_images.first
+ other = Attachment.create!(
+ :container => attachment,
+ :file => uploaded_test_file("testfile.txt", "text/plain"),
+ :filename => 'other.txt',
+ :author => User.find(1)
+ )
+
+ Attachment.clear_markdownized_previews
+
+ assert_not File.exist?(attachment.markdownized_preview_cache_path)
+ assert_not Attachment.exists?(image.id)
+ assert Attachment.exists?(attachment.id)
+ assert Attachment.exists?(other.id)
+
+ assert_include 'markdownized_preview_image_1.png', attachment.reload.markdownized_preview_content
+ assert_equal ['markdownized_preview_image_1.png'], attachment.reload.markdownized_preview_images.map(&:filename)
+ assert Attachment.exists?(other.id)
+ end
+
+ def test_markdownized_preview_images_should_have_the_permissions_of_the_attachment
+ skip unless Redmine::Markdownizer.images_available?
+
+ attachment = docx_attachment
+ attachment.markdownized_preview_content
+ image = attachment.reload.markdownized_preview_images.first
+
+ assert image.visible?(User.find(2))
+ assert_equal attachment.visible?(User.anonymous), image.visible?(User.anonymous)
+ assert_not image.editable?(User.find(1))
+ assert_not image.deletable?(User.find(1))
+
+ Issue.find(1).update_column(:is_private, true)
+ attachment.reload.container.reload
+ assert_not image.reload.visible?(User.find(7))
+ end
+
+ def test_markdownized_preview_content_should_not_read_files_referenced_by_a_document
+ skip unless Redmine::Markdownizer.images_available?
+
+ attachment = Attachment.create!(
+ :container => Issue.find(1),
+ :file => uploaded_test_file(
+ "libreoffice-writer-linked-image.odt",
+ "application/vnd.oasis.opendocument.text"
+ ),
+ :author => User.find(1)
+ )
+
+ content = attachment.markdownized_preview_content
+ assert_include 'hostname', content
+ assert_not_include 'src=', content
+ assert_equal 0, attachment.reload.markdownized_preview_images.count
+ end
+
if convert_installed?
def test_thumbnail_should_generate_the_thumbnail
set_fixtures_attachments_directory