Project

General

Profile

Defect #31968 » attachment.rb

Amit Mehendale, 2019-08-28 14:41

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

    
18
require "digest"
19
require "fileutils"
20
require "mimemagic/overlay"
21

    
22
class Attachment < ActiveRecord::Base
23
  include Redmine::SafeAttributes
24
  belongs_to :container, :polymorphic => true
25
  belongs_to :author, :class_name => "User"
26

    
27
  validates_presence_of :filename, :author
28
  validates_length_of :filename, :maximum => 255
29
  validates_length_of :disk_filename, :maximum => 255
30
  validates_length_of :description, :maximum => 255
31
  validate :validate_max_file_size, :validate_file_extension, :validate_file_content
32

    
33

    
34
  acts_as_event :title => :filename,
35
                :url => Proc.new {|o| {:controller => 'attachments', :action => 'show', :id => o.id, :filename => o.filename}}
36

    
37
  acts_as_activity_provider :type => 'files',
38
                            :permission => :view_files,
39
                            :author_key => :author_id,
40
                            :scope => select("#{Attachment.table_name}.*").
41
                                      joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
42
                                            "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )")
43

    
44
  acts_as_activity_provider :type => 'documents',
45
                            :permission => :view_documents,
46
                            :author_key => :author_id,
47
                            :scope => select("#{Attachment.table_name}.*").
48
                                      joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
49
                                            "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
50

    
51
  cattr_accessor :storage_path
52
  @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
53

    
54
  cattr_accessor :thumbnails_storage_path
55
  @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
56

    
57
  before_create :files_to_final_location
58
  after_rollback :delete_from_disk, :on => :create
59
  after_commit :delete_from_disk, :on => :destroy
60
  after_commit :reuse_existing_file_if_possible, :on => :create
61

    
62
  safe_attributes 'filename', 'content_type', 'description'
63

    
64
  # Returns an unsaved copy of the attachment
65
  def copy(attributes=nil)
66
    copy = self.class.new
67
    copy.attributes = self.attributes.dup.except("id", "downloads")
68
    copy.attributes = attributes if attributes
69
    copy
70
  end
71

    
72
  def validate_max_file_size
73
    if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
74
      errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
75
    end
76
  end
77

    
78
  def validate_file_extension
79
    if @temp_file
80
      extension = File.extname(filename)
81
	logger.info( "RAJESH1")
82
      unless self.class.valid_extension?(extension) 
83
        errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
84
      end
85
    end
86
  end
87
###################
88
 def validate_file_content
89
    if @temp_file
90

    
91
      mimemagic_content_type  = MimeMagic.by_magic(@temp_file)
92
      content_type = Redmine::MimeType.of(filename)
93
      logger.error ("RAJESH234")
94
      if mimemagic_content_type != content_type
95
        errors.add(:base, l(:error_attachment_mime_not_allowed, :content_type=> mimemagic_content_type))
96
      else
97
        #puts "I can't guess the number"
98
      end
99
    end
100
  end
101
###################
102

    
103
  def file=(incoming_file)
104
    unless incoming_file.nil?
105
      @temp_file = incoming_file
106
        if @temp_file.respond_to?(:original_filename)
107
          self.filename = @temp_file.original_filename
108
          self.filename.force_encoding("UTF-8")
109
        end
110
        if @temp_file.respond_to?(:content_type)
111
          self.content_type = @temp_file.content_type.to_s.chomp
112
        end
113
        self.filesize = @temp_file.size
114
    end
115
  end
116

    
117
  def file
118
    nil
119
  end
120

    
121
  def filename=(arg)
122
    write_attribute :filename, sanitize_filename(arg.to_s)
123
    filename
124
  end
125

    
126
  # Copies the temporary file to its final location
127
  # and computes its MD5 hash
128
  def files_to_final_location
129
    if @temp_file
130
      self.disk_directory = target_directory
131
      self.disk_filename = Attachment.disk_filename(filename, disk_directory)
132
      logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
133
      path = File.dirname(diskfile)
134
      unless File.directory?(path)
135
        FileUtils.mkdir_p(path)
136
      end
137
      sha = Digest::SHA256.new
138
      File.open(diskfile, "wb") do |f|
139
        if @temp_file.respond_to?(:read)
140
          buffer = ""
141
          while (buffer = @temp_file.read(8192))
142
            f.write(buffer)
143
            sha.update(buffer)
144
          end
145
        else
146
          f.write(@temp_file)
147
          sha.update(@temp_file)
148
        end
149
      end
150
      self.digest = sha.hexdigest
151
    end
152
    @temp_file = nil
153

    
154
    if content_type.blank? && filename.present?
155
      self.content_type = Redmine::MimeType.of(filename)
156
    end
157
    # Don't save the content type if it's longer than the authorized length
158
    if self.content_type && self.content_type.length > 255
159
      self.content_type = nil
160
    end
161
  end
162

    
163
  # Deletes the file from the file system if it's not referenced by other attachments
164
  def delete_from_disk
165
    if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
166
      delete_from_disk!
167
    end
168
  end
169

    
170
  # Returns file's location on disk
171
  def diskfile
172
    File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
173
  end
174

    
175
  def title
176
    title = filename.dup
177
    if description.present?
178
      title << " (#{description})"
179
    end
180
    title
181
  end
182

    
183
  def increment_download
184
    increment!(:downloads)
185
  end
186

    
187
  def project
188
    container.try(:project)
189
  end
190

    
191
  def visible?(user=User.current)
192
    if container_id
193
      container && container.attachments_visible?(user)
194
    else
195
      author == user
196
    end
197
  end
198

    
199
  def editable?(user=User.current)
200
    if container_id
201
      container && container.attachments_editable?(user)
202
    else
203
      author == user
204
    end
205
  end
206

    
207
  def deletable?(user=User.current)
208
    if container_id
209
      container && container.attachments_deletable?(user)
210
    else
211
      author == user
212
    end
213
  end
214

    
215
  def image?
216
    !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
217
  end
218

    
219
  def thumbnailable?
220
    image?
221
  end
222

    
223
  # Returns the full path the attachment thumbnail, or nil
224
  # if the thumbnail cannot be generated.
225
  def thumbnail(options={})
226
    if thumbnailable? && readable?
227
      size = options[:size].to_i
228
      if size > 0
229
        # Limit the number of thumbnails per image
230
        size = (size / 50) * 50
231
        # Maximum thumbnail size
232
        size = 800 if size > 800
233
      else
234
        size = Setting.thumbnails_size.to_i
235
      end
236
      size = 100 unless size > 0
237
      target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
238

    
239
      begin
240
        Redmine::Thumbnail.generate(self.diskfile, target, size)
241
      rescue => e
242
        logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
243
        return nil
244
      end
245
    end
246
  end
247

    
248
  # Deletes all thumbnails
249
  def self.clear_thumbnails
250
    Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
251
      File.delete file
252
    end
253
  end
254

    
255
  def is_text?
256
    Redmine::MimeType.is_type?('text', filename) || Redmine::SyntaxHighlighting.filename_supported?(filename)
257
  end
258

    
259
  def is_image?
260
    Redmine::MimeType.is_type?('image', filename)
261
  end
262

    
263
  def is_diff?
264
    self.filename =~ /\.(patch|diff)$/i
265
  end
266

    
267
  def is_pdf?
268
    #Redmine::MimeType.of(filename) == "application/pdf"
269
     Redmine::MimeType.of(filename) == "application/pdf" && MimeMagic.by_magic(File.open(filename)).type == 'application/pdf'
270
  end
271

    
272
  def is_video?
273
    Redmine::MimeType.is_type?('video', filename)
274
  end
275

    
276
  def is_audio?
277
    Redmine::MimeType.is_type?('audio', filename)
278
  end
279

    
280
  def previewable?
281
    is_text? || is_image? || is_video? || is_audio?
282
  end
283

    
284
  # Returns true if the file is readable
285
  def readable?
286
    disk_filename.present? && File.readable?(diskfile)
287
  end
288

    
289
  # Returns the attachment token
290
  def token
291
    "#{id}.#{digest}"
292
  end
293

    
294
  # Finds an attachment that matches the given token and that has no container
295
  def self.find_by_token(token)
296
    if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
297
      attachment_id, attachment_digest = $1, $2
298
      attachment = Attachment.find_by(:id => attachment_id, :digest => attachment_digest)
299
      if attachment && attachment.container.nil?
300
        attachment
301
      end
302
    end
303
  end
304

    
305
  # Bulk attaches a set of files to an object
306
  #
307
  # Returns a Hash of the results:
308
  # :files => array of the attached files
309
  # :unsaved => array of the files that could not be attached
310
  def self.attach_files(obj, attachments)
311
    result = obj.save_attachments(attachments, User.current)
312
    obj.attach_saved_attachments
313
    result
314
  end
315

    
316
  # Updates the filename and description of a set of attachments
317
  # with the given hash of attributes. Returns true if all
318
  # attachments were updated.
319
  #
320
  # Example:
321
  #   Attachment.update_attachments(attachments, {
322
  #     4 => {:filename => 'foo'},
323
  #     7 => {:filename => 'bar', :description => 'file description'}
324
  #   })
325
  #
326
  def self.update_attachments(attachments, params)
327
    params = params.transform_keys {|key| key.to_i}
328

    
329
    saved = true
330
    transaction do
331
      attachments.each do |attachment|
332
        if p = params[attachment.id]
333
          attachment.filename = p[:filename] if p.key?(:filename)
334
          attachment.description = p[:description] if p.key?(:description)
335
          saved &&= attachment.save
336
        end
337
      end
338
      unless saved
339
        raise ActiveRecord::Rollback
340
      end
341
    end
342
    saved
343
  end
344

    
345
  def self.latest_attach(attachments, filename)
346
    attachments.sort_by(&:created_on).reverse.detect do |att|
347
      filename.casecmp(att.filename) == 0
348
    end
349
  end
350

    
351
  def self.prune(age=1.day)
352
    Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
353
  end
354

    
355
  # Moves an existing attachment to its target directory
356
  def move_to_target_directory!
357
    return unless !new_record? & readable?
358

    
359
    src = diskfile
360
    self.disk_directory = target_directory
361
    dest = diskfile
362

    
363
    return if src == dest
364

    
365
    if !FileUtils.mkdir_p(File.dirname(dest))
366
      logger.error "Could not create directory #{File.dirname(dest)}" if logger
367
      return
368
    end
369

    
370
    if !FileUtils.mv(src, dest)
371
      logger.error "Could not move attachment from #{src} to #{dest}" if logger
372
      return
373
    end
374

    
375
    update_column :disk_directory, disk_directory
376
  end
377

    
378
  # Moves existing attachments that are stored at the root of the files
379
  # directory (ie. created before Redmine 2.3) to their target subdirectories
380
  def self.move_from_root_to_target_directory
381
    Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
382
      attachment.move_to_target_directory!
383
    end
384
  end
385

    
386
  # Updates digests to SHA256 for all attachments that have a MD5 digest
387
  # (ie. created before Redmine 3.4)
388
  def self.update_digests_to_sha256
389
    Attachment.where("length(digest) < 64").find_each do |attachment|
390
      attachment.update_digest_to_sha256!
391
    end
392
  end
393

    
394
  # Updates attachment digest to SHA256
395
  def update_digest_to_sha256!
396
    if readable?
397
      sha = Digest::SHA256.new
398
      File.open(diskfile, 'rb') do |f|
399
        while buffer = f.read(8192)
400
          sha.update(buffer)
401
        end
402
      end
403
      update_column :digest, sha.hexdigest
404
    end
405
  end
406

    
407
  # Returns true if the extension is allowed regarding allowed/denied
408
  # extensions defined in application settings, otherwise false
409
  def self.valid_extension?(extension)
410
    denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
411
      Setting.send(setting)
412
    end
413
    if denied.present? && extension_in?(extension, denied)
414
      return false
415
    end
416
    if allowed.present? && !extension_in?(extension, allowed)
417
      return false
418
    end
419
    true
420
  end
421

    
422
  # Returns true if extension belongs to extensions list.
423
  def self.extension_in?(extension, extensions)
424
    extension = extension.downcase.sub(/\A\.+/, '')
425

    
426
    unless extensions.is_a?(Array)
427
      extensions = extensions.to_s.split(",").map(&:strip)
428
    end
429
    extensions = extensions.map {|s| s.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
430
    extensions.include?(extension)
431
  end
432

    
433
  # Returns true if attachment's extension belongs to extensions list.
434
  def extension_in?(extensions)
435
    self.class.extension_in?(File.extname(filename), extensions)
436
  end
437

    
438
  # returns either MD5 or SHA256 depending on the way self.digest was computed
439
  def digest_type
440
    digest.size < 64 ? "MD5" : "SHA256" if digest.present?
441
  end
442

    
443
  private
444

    
445
  def reuse_existing_file_if_possible
446
    original_diskfile = nil
447

    
448
    reused = with_lock do
449
      if existing = Attachment
450
                      .where(digest: self.digest, filesize: self.filesize)
451
                      .where('id <> ? and disk_filename <> ?',
452
                             self.id, self.disk_filename)
453
                      .first
454
        existing.with_lock do
455

    
456
          original_diskfile = self.diskfile
457
          existing_diskfile = existing.diskfile
458

    
459
          if File.readable?(original_diskfile) &&
460
            File.readable?(existing_diskfile) &&
461
            FileUtils.identical?(original_diskfile, existing_diskfile)
462

    
463
            self.update_columns disk_directory: existing.disk_directory,
464
                                disk_filename: existing.disk_filename
465
          end
466
        end
467
      end
468
    end
469
    if reused
470
      File.delete(original_diskfile)
471
    end
472
  rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound
473
    # Catch and ignore lock errors. It is not critical if deduplication does
474
    # not happen, therefore we do not retry.
475
    # with_lock throws ActiveRecord::RecordNotFound if the record isnt there
476
    # anymore, thats why this is caught and ignored as well.
477
  end
478

    
479

    
480
  # Physically deletes the file from the file system
481
  def delete_from_disk!
482
    if disk_filename.present? && File.exist?(diskfile)
483
      File.delete(diskfile)
484
    end
485
  end
486

    
487
  def sanitize_filename(value)
488
    # get only the filename, not the whole path
489
    just_filename = value.gsub(/\A.*(\\|\/)/m, '')
490

    
491
    # Finally, replace invalid characters with underscore
492
    just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
493
  end
494

    
495
  # Returns the subdirectory in which the attachment will be saved
496
  def target_directory
497
    time = created_on || DateTime.now
498
    time.strftime("%Y/%m")
499
  end
500

    
501
  # Returns an ASCII or hashed filename that do not
502
  # exists yet in the given subdirectory
503
  def self.disk_filename(filename, directory=nil)
504
    timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
505
    ascii = ''
506
    if filename =~ %r{^[a-zA-Z0-9_\.\-]*$} && filename.length <= 50
507
      ascii = filename
508
    else
509
      ascii = Digest::MD5.hexdigest(filename)
510
      # keep the extension if any
511
      ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
512
    end
513
    while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
514
      timestamp.succ!
515
    end
516
    "#{timestamp}_#{ascii}"
517
  end
518
end
(2-2/2)