Project

General

Profile

Feature #43484 » 0004-Detect-attachment-content-type-from-file-contents-in.patch

Go MAEDA, 2026-03-04 10:03

View differences:

app/assets/javascripts/attachments.js
122 122
  uploadUrl = uploadUrl + '?attachment_id=' + attachmentId;
123 123
  if (blob instanceof window.Blob) {
124 124
    uploadUrl += '&filename=' + encodeURIComponent(blob.name);
125
    uploadUrl += '&content_type=' + encodeURIComponent(blob.type);
126 125
  }
127 126

  
128 127
  return $.ajax(uploadUrl, {
app/controllers/attachments_controller.rb
110 110
    @attachment = Attachment.new(:file => raw_request_body)
111 111
    @attachment.author = User.current
112 112
    @attachment.filename = params[:filename].presence || Redmine::Utils.random_hex(16)
113
    @attachment.content_type = params[:content_type].presence
114 113
    saved = @attachment.save
115 114

  
116 115
    respond_to do |format|
app/models/attachment.rb
83 83
  cattr_accessor :thumbnails_storage_path
84 84
  @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
85 85

  
86
  before_create :files_to_final_location
86
  before_create :set_content_type, :files_to_final_location
87 87
  after_commit :delete_from_disk, :on => :destroy
88 88
  after_commit :reuse_existing_file_if_possible, :on => :create
89 89
  after_rollback :delete_from_disk, :on => :create
90 90

  
91
  safe_attributes 'filename', 'content_type', 'description'
91
  safe_attributes 'filename', 'description'
92 92

  
93 93
  # Returns an unsaved copy of the attachment
94 94
  def copy(attributes=nil)
......
118 118
        self.filename = @temp_file.original_filename
119 119
        self.filename.force_encoding("UTF-8")
120 120
      end
121
      if @temp_file.respond_to?(:content_type)
122
        self.content_type = @temp_file.content_type.to_s.chomp
123
      end
124 121
      self.filesize = @temp_file.size
125 122
    end
126 123
  end
......
134 131
    filename
135 132
  end
136 133

  
134
  def set_content_type
135
    return unless @temp_file
136

  
137
    io = @temp_file.respond_to?(:read) ? @temp_file : StringIO.new(@temp_file)
138
    self.content_type = Marcel::MimeType.for(io, name: filename)
139
    @temp_file.rewind if @temp_file.respond_to?(:rewind)
140
  end
141

  
137 142
  # Copies the temporary file to its final location
138 143
  # and computes its hash
139 144
  def files_to_final_location
......
157 162
      self.digest = sha.hexdigest
158 163
    end
159 164
    @temp_file = nil
160

  
161
    if content_type.blank? && filename.present?
162
      self.content_type = Redmine::MimeType.of(filename)
163
    end
164
    # Don't save the content type if it's longer than the authorized length
165
    if self.content_type && self.content_type.length > 255
166
      self.content_type = nil
167
    end
168 165
  end
169 166

  
170 167
  # Deletes the file from the file system if it's not referenced by other attachments
lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb
127 127
                  next
128 128
                end
129 129
                a.filename = attachment['filename'] unless attachment['filename'].blank?
130
                a.content_type = attachment['content_type'] unless attachment['content_type'].blank?
130
                # Re-evaluate MIME type after rename. Marcel uses the filename as a hint
131
                if a.filename_changed?
132
                  File.open(a.diskfile, 'rb') do |io|
133
                    a.content_type = Marcel::MimeType.for(io, name: a.filename)
134
                  end
135
                end
131 136
              end
132 137
              next unless a
133 138
              a.description = attachment['description'].to_s.strip
test/integration/api_test/attachments_test.rb
142 142
    assert_nil attachment.container
143 143
    assert_equal 2, attachment.author_id
144 144
    assert_equal 'File content'.size, attachment.filesize
145
    assert attachment.content_type.blank?
145
    assert_equal 'application/octet-stream', attachment.content_type
146 146
    assert attachment.filename.present?
147 147
    assert_match %r{\d+_[0-9a-z]+}, attachment.diskfile
148 148
    assert File.exist?(attachment.diskfile)
test/integration/api_test/files_test.rb
83 83
    assert_response :bad_request
84 84
  end
85 85

  
86
  test "POST /projects/:project_id/files.json should accept :filename, :description, :content_type as optional parameters" do
86
  test "POST /projects/:project_id/files.json should accept :filename, :description as optional parameters" do
87 87
    set_tmp_attachments_directory
88 88
    post(
89 89
      '/uploads.xml',
......
94 94
      { "file": {
95 95
          "filename": "New filename",
96 96
          "description": "New description",
97
          "content_type": "application/txt",
98 97
          "token": "#{token}"
99 98
        }
100 99
      }
......
106 105
    assert_response :success
107 106
    assert_equal "New filename", Attachment.last.filename
108 107
    assert_equal "New description", Attachment.last.description
109
    assert_equal "application/txt", Attachment.last.content_type
110 108
  end
111 109

  
112 110
  test "POST /projects/:project_id/files.json should accept :version_id to attach the files to a version" do
test/integration/attachments_test.rb
33 33
    assert_equal 'text/plain', attachment.content_type
34 34
  end
35 35

  
36
  def test_upload_should_accept_content_type_param
36
  def test_upload_should_detect_file_content_type
37 37
    log_user('jsmith', 'jsmith')
38 38
    assert_difference 'Attachment.count' do
39 39
      post(
40
        "/uploads.js?attachment_id=1&filename=foo&content_type=image/jpeg",
41
        :params => "File content",
40
        "/uploads.js?attachment_id=1&filename=foo",
41
        :params => "\xCA\xFE\xBA\xBE", # Java class file magic bytes
42 42
        :headers => {"CONTENT_TYPE" => 'application/octet-stream'})
43 43
      assert_response :success
44 44
    end
45 45
    attachment = Attachment.order(:id => :desc).first
46
    assert_equal 'image/jpeg', attachment.content_type
46
    assert_equal 'application/java-vm', attachment.content_type
47 47
  end
48 48

  
49 49
  def test_upload_as_js_and_attach_to_an_issue
test/unit/attachment_test.rb
54 54
    assert_equal 59, File.size(a.diskfile)
55 55
  end
56 56

  
57
  def test_create_should_clear_content_type_if_too_long
57
  def test_create_should_not_trust_given_content_type_attribute
58
    # Text file upload with a long bogus content type
58 59
    a = Attachment.new(:container => Issue.find(1),
59
                       :file => uploaded_test_file("testfile.txt", "text/plain"),
60
                       :file => uploaded_test_file("testfile.txt", nil),
60 61
                       :author => User.find(1),
61 62
                       :content_type => 'a'*300)
62 63
    assert a.save
63 64
    a.reload
64
    assert_nil a.content_type
65
    assert_equal 'text/plain', a.content_type
66
  end
67

  
68
  def test_create_should_not_trust_client_declared_content_type
69
    # PDF file uploaded with a wrong content type "text/plain"
70
    a = Attachment.new(:container => Issue.find(1),
71
                        :file => uploaded_test_file("hello.pdf", "text/plain"),
72
                        :author => User.find(1))
73
    assert a.save
74
    a.reload
75
    assert_equal 'application/pdf', a.content_type
65 76
  end
66 77

  
67 78
  def test_shorted_filename_if_too_long
......
413 424
    assert_equal 'text/plain', attachment.content_type
414 425
  end
415 426

  
427
  test "Attachment.attach_files should recalculate the content_type when filename changes" do
428
    @project = Project.find(1)
429
    attachment = Attachment.create!(
430
      :file => mock_file_with_options(:original_filename => "test.bin", :content => "\x00\x01\x02".b),
431
      :author_id => 1,
432
      :created_on => 2.days.ago
433
    )
434
    assert_equal 'application/octet-stream', attachment.content_type
435

  
436
    Attachment.attach_files(@project, {'1' => {'token' => attachment.token, 'filename' => 'renamed.txt'}})
437
    attachment.reload
438
    assert_equal 'text/plain', attachment.content_type
439
  end
440

  
416 441
  def test_update_digest_to_sha256_should_update_digest
417 442
    set_fixtures_attachments_directory
418 443
    attachment = Attachment.find 6
(4-4/4)