Project

General

Profile

Defect #44358 » 0001-Defect-44358-Fix-images-in-markdownized-previews.patch

Florian Walchshofer, 2026-08-28 23:35

View differences:

app/controllers/attachments_controller.rb
20 20
class AttachmentsController < ApplicationController
21 21
  include ActionView::Helpers::NumberHelper
22 22

  
23
  before_action :find_attachment, :only => [:show, :download, :thumbnail, :update, :destroy]
23
  before_action :find_attachment, :only => [:show, :download, :thumbnail, :update, :destroy, :preview_media]
24 24
  before_action :find_container, :only => [:edit_all, :update_all, :download_all]
25 25
  before_action :find_downloadable_attachments, :only => :download_all
26 26
  before_action :find_editable_attachments, :only => [:edit_all, :update_all]
27
  before_action :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
27
  before_action :file_readable, :read_authorize, :only => [:show, :download, :thumbnail, :preview_media]
28 28
  before_action :update_authorize, :only => :update
29 29
  before_action :delete_authorize, :only => :destroy
30 30
  before_action :authorize_global, :only => :upload
......
33 33
  # MIME type text/javascript.
34 34
  skip_after_action :verify_same_origin_request, :only => :download
35 35

  
36
  accept_api_auth :show, :download, :thumbnail, :upload, :update, :destroy
36
  accept_api_auth :show, :download, :thumbnail, :upload, :update, :destroy, :preview_media
37 37

  
38 38
  def show
39 39
    respond_to do |format|
......
101 101
    end
102 102
  end
103 103

  
104
  def preview_media
105
    if (media = @attachment.preview_media(params[:path]))
106
      if stale?(etag: media, template: false)
107
        send_file(media,
108
          :type => Redmine::MimeType.of(media).presence || 'application/octet-stream',
109
          :filename => "preview_#{@attachment.id}_#{File.basename(media)}",
110
          :disposition => 'inline')
111
      end
112
    else
113
      head :not_found
114
    end
115
  end
116

  
104 117
  def upload
105 118
    # Make sure that API users get used to set this content type
106 119
    # as it won't trigger Rails' automatic parsing of the request body for parameters
app/models/attachment.rb
297 297
  end
298 298

  
299 299
  def self.clear_markdownized_previews
300
    Dir.glob(File.join(markdownized_previews_storage_path, "*.md")).each do |file|
301
      File.delete file
302
    end
300
    FileUtils.rm_rf(Dir.glob(File.join(markdownized_previews_storage_path, '*')))
303 301
  end
304 302

  
305 303
  def is_text?
......
342 340
    return nil unless markdownized_previewable?
343 341

  
344 342
    target = markdownized_preview_cache_path
345
    if Redmine::Markdownizer.convert(diskfile, target)
343
    if Redmine::Markdownizer.convert(diskfile, target, id)
346 344
      File.read(target, :mode => "rb")
347 345
    end
348 346
  rescue => e
......
355 353
    nil
356 354
  end
357 355

  
356
  def preview_media(path)
357
    base_path = File.expand_path(File.join(self.class.markdownized_previews_storage_path, id.to_s))
358
    file_path = File.expand_path(path, base_path)
359

  
360
    return nil unless file_path.start_with?(base_path)
361
    return nil unless File.file?(file_path)
362

  
363
    file_path
364
  end
365

  
366
  def markdownized_preview_directory
367
    File.join(self.class.markdownized_previews_storage_path, id.to_s)
368
  end
369

  
358 370
  def markdownized_preview_cache_path
359
    File.join(self.class.markdownized_previews_storage_path, "#{digest}_#{filesize}.md")
371
    File.join(markdownized_preview_directory, "preview.md")
360 372
  end
361 373

  
362 374
  def previewable?
......
590 602
    Dir[thumbnail_path("*")].each do |thumb|
591 603
      File.delete(thumb)
592 604
    end
593
    FileUtils.rm_f(markdownized_preview_cache_path)
605
    FileUtils.rm_rf(markdownized_preview_directory)
594 606
  end
595 607

  
596 608
  def thumbnail_path(size)
config/routes.rb
328 328
  get 'attachments/download/:id/:filename', :to => 'attachments#download', :id => /\d+/, :filename => /.*/, :as => 'download_named_attachment', :format => 'html'
329 329
  get 'attachments/download/:id', :to => 'attachments#download', :id => /\d+/
330 330
  get 'attachments/thumbnail/:id(/:size)', :to => 'attachments#thumbnail', :id => /\d+/, :size => /\d+/, :as => 'thumbnail'
331
  get 'attachments/markdownized_previews/:id/*path', :to => 'attachments#preview_media', :id => /\d+/, :format => false
331 332
  resources :attachments, :only => [:show, :update, :destroy]
332 333

  
333 334
  # register plugin object types with ObjectTypeConstraint.register_object_type(PluginModel.name.underscore.pluralize')
lib/redmine/markdownizer.rb
35 35
      markdownizable_extensions.include?(File.extname(filename.to_s).downcase)
36 36
    end
37 37

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

  
......
45 45
      end
46 46

  
47 47
      directory = File.dirname(target)
48
      basedir = File.dirname(Attachment.markdownized_previews_storage_path)
48 49
      FileUtils.mkdir_p(directory)
49
      args = [COMMAND, source, "-t", "gfm"]
50
      args = [COMMAND, source, "-t", "gfm", "--extract-media=markdownized_previews/#{attachment_id}/"]
50 51
      pid = nil
51 52
      output = Tempfile.new('markdownized-preview')
52 53

  
53 54
      begin
54 55
        Timeout.timeout(PREVIEW_GENERATION_TIMEOUT) do
55
          pid = Process.spawn(*args, out: output.path)
56
          pid = Process.spawn(*args, chdir: basedir, out: output.path)
56 57
          _, status = Process.wait2(pid)
57 58
          unless status.success?
58 59
            logger.error("Markdownized preview generation failed (#{status.exitstatus}):\nCommand: #{args.shelljoin}")
test/functional/attachments_controller_test.rb
287 287
    assert_equal 'text/html', @response.media_type
288 288
    assert_select 'div.filecontent.wiki', :text => /Redmine is a flexible project management web application/
289 289
    assert_select '.nodata', :count => 0
290
    assert_select 'div.filecontent.wiki img', 1
291
    img = css_select('div.filecontent.wiki img').first
292
    assert_match(%r{markdownized_previews/#{a.id}/}, img['src'])
293
    path = img['src'][%r{markdownized_previews/#{a.id}/(.+)$}, 1]
294

  
295
    get(:preview_media, :params => {:id => a.id, :path => path})
296

  
297
    assert_response :success
298
    assert_equal 'image/png', response.media_type
290 299
  end
291 300

  
292 301
  def test_show_libreoffice_writer
......
309 318
    assert_equal 'text/html', @response.media_type
310 319
    assert_select 'div.filecontent.wiki', :text => /Redmine is a flexible project management web application/
311 320
    assert_select '.nodata', :count => 0
321
    assert_select 'div.filecontent.wiki img', 1
322
    img = css_select('div.filecontent.wiki img').first
323
    assert_match(%r{markdownized_previews/#{a.id}/}, img['src'])
324
    path = img['src'][%r{markdownized_previews/#{a.id}/(.+)$}, 1]
325

  
326
    get(:preview_media, :params => {:id => a.id, :path => path})
327

  
328
    assert_response :success
329
    assert_equal 'image/png', response.media_type
330
  end
331

  
332
  def test_preview_media_from_private_issue_without_permission
333
    attachment = Attachment.find(15)
334
    FileUtils.mkdir_p(File.join(attachment.markdownized_preview_directory, 'Pictures'))
335
    File.binwrite(File.join(attachment.markdownized_preview_directory, 'Pictures', 'image.png'), 'PNG')
336

  
337
    get(:preview_media, :params => {:id => attachment.id, :path => 'Pictures/image.png'})
338

  
339
    assert_response :unauthorized
340
  end
341

  
342
  def test_preview_media_from_private_issue_with_permission
343
    @request.session[:user_id] = 2
344

  
345
    attachment = Attachment.find(15)
346
    FileUtils.mkdir_p(File.join(attachment.markdownized_preview_directory, 'Pictures'))
347
    File.binwrite(File.join(attachment.markdownized_preview_directory, 'Pictures', 'image.png'), 'PNG')
348

  
349
    get(:preview_media, :params => {:id => attachment.id, :path => 'Pictures/image.png'})
350

  
351
    assert_response :success
352
    assert_equal 'image/png', response.media_type
353
  end
354

  
355
  def test_preview_media_should_be_denied_without_permission
356
    @request.session[:user_id] = 3
357

  
358
    attachment = Attachment.find(15)
359
    FileUtils.mkdir_p(File.join(attachment.markdownized_preview_directory, 'Pictures'))
360
    File.binwrite(File.join(attachment.markdownized_preview_directory, 'Pictures', 'image.png'), 'PNG')
361

  
362
    get(:preview_media, :params => {:id => attachment.id, :path => 'Pictures/image.png'})
363

  
364
    assert_response :forbidden
365
  end
366

  
367
  def test_preview_media_should_return_404_for_missing_file
368
    @request.session[:user_id] = 2
369

  
370
    get(:preview_media, :params => {:id => 16, :path => 'media/missing.png'})
371

  
372
    assert_response :not_found
373
  end
374

  
375
  def test_preview_media_should_return_404_for_invalid_path
376
    @request.session[:user_id] = 2
377

  
378
    get(:preview_media, :params => {:id => 16, :path => '../secret.txt'})
379

  
380
    assert_response :not_found
312 381
  end
313 382

  
314 383
  def test_show_other_with_no_preview
test/integration/routing/attachments_test.rb
31 31
    should_route 'GET /attachments/thumbnail/1' => 'attachments#thumbnail', :id => '1'
32 32
    should_route 'GET /attachments/thumbnail/1/200' => 'attachments#thumbnail', :id => '1', :size => '200'
33 33

  
34
    should_route 'GET /attachments/markdownized_previews/1/pictures/image.png' => 'attachments#preview_media', :id => '1', :path => 'pictures/image.png'
35

  
34 36
    should_route 'DELETE /attachments/1' => 'attachments#destroy', :id => '1'
35 37

  
36 38
    should_route 'GET /attachments/issues/1/edit' => 'attachments#edit_all', :object_type => 'issues', :object_id => '1'
test/unit/attachment_test.rb
683 683
      ),
684 684
      :author => User.find(1)
685 685
    )
686
    preview = attachment.markdownized_preview_cache_path
687
    FileUtils.mkdir_p(File.dirname(preview))
688
    File.write(preview, "preview")
689
    assert File.exist?(preview)
686
    preview_dir = attachment.markdownized_preview_directory
687
    attachment.markdownized_preview_content
688
    assert Dir.exist?(preview_dir)
690 689

  
691 690
    attachment.send(:delete_from_disk!)
692
    assert_not File.exist?(preview)
691
    assert_not Dir.exist?(preview_dir)
693 692
  end
694 693

  
695 694
  if convert_installed?
(1-1/5)