Class: Attachment

Inherits:
ActiveRecord::Base
  • Object
show all
Includes:
Redmine::SafeAttributes
Defined in:
app/models/attachment.rb

Constant Summary collapse

@@storage_path =
Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
@@thumbnails_storage_path =
File.join(Rails.root, "tmp", "thumbnails")

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Redmine::SafeAttributes

#delete_unsafe_attributes, included, #safe_attribute?, #safe_attribute_names, #safe_attributes=

Class Method Details

.archive_attachments(attachments) ⇒ Object

[View source] [View on GitHub]

377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'app/models/attachment.rb', line 377

def self.archive_attachments(attachments)
  attachments = attachments.select(&:readable?)
  return nil if attachments.blank?

  Zip.unicode_names = true
  archived_file_names = []
  buffer = Zip::OutputStream.write_buffer do |zos|
    attachments.each do |attachment|
      filename = attachment.filename
      # rename the file if a file with the same name already exists
      dup_count = 0
      while archived_file_names.include?(filename)
        dup_count += 1
        extname = File.extname(attachment.filename)
        basename = File.basename(attachment.filename, extname)
        filename = "#{basename}(#{dup_count})#{extname}"
      end
      zos.put_next_entry(filename)
      zos << IO.binread(attachment.diskfile)
      archived_file_names << filename
    end
  end
  buffer.string
ensure
  buffer&.close
end

.attach_files(obj, attachments) ⇒ Object

Bulk attaches a set of files to an object

Returns a Hash of the results: :files => array of the attached files :unsaved => array of the files that could not be attached

[View source] [View on GitHub]

330
331
332
333
334
# File 'app/models/attachment.rb', line 330

def self.attach_files(obj, attachments)
  result = obj.save_attachments(attachments, User.current)
  obj.attach_saved_attachments
  result
end

.clear_thumbnailsObject

Deletes all thumbnails

[View source] [View on GitHub]

262
263
264
265
266
# File 'app/models/attachment.rb', line 262

def self.clear_thumbnails
  Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
    File.delete file
  end
end

.create_diskfile(filename, directory = nil, &block) ⇒ Object

Claims a unique ASCII or hashed filename, yields the open file handle

[View source] [View on GitHub]

557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
# File 'app/models/attachment.rb', line 557

def create_diskfile(filename, directory=nil, &block)
  timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
  ascii = ''
  if %r{^[a-zA-Z0-9_\.\-]*$}.match?(filename) && filename.length <= 50
    ascii = filename
  else
    ascii = Digest::MD5.hexdigest(filename)
    # keep the extension if any
    ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
  end

  path = File.join storage_path, directory.to_s
  FileUtils.mkdir_p(path) unless File.directory?(path)
  begin
    name = "#{timestamp}_#{ascii}"
    File.open(
      File.join(path, name),
      flags: File::CREAT | File::EXCL | File::WRONLY,
      binmode: true,
      &block
    )
  rescue Errno::EEXIST
    timestamp.succ!
    retry
  end
end

.extension_in?(extension, extensions) ⇒ Boolean

Returns true if extension belongs to extensions list.

Returns:

  • (Boolean)
[View source] [View on GitHub]

473
474
475
476
477
478
479
480
481
# File 'app/models/attachment.rb', line 473

def self.extension_in?(extension, extensions)
  extension = extension.downcase.sub(/\A\.+/, '')

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

.find_by_token(token) ⇒ Object

Finds an attachment that matches the given token and that has no container

[View source] [View on GitHub]

315
316
317
318
319
320
321
322
323
# File 'app/models/attachment.rb', line 315

def self.find_by_token(token)
  if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
    attachment_id, attachment_digest = $1, $2
    attachment = Attachment.find_by(:id => attachment_id, :digest => attachment_digest)
    if attachment && attachment.container.nil?
      attachment
    end
  end
end

.latest_attach(attachments, filename) ⇒ Object

[View source] [View on GitHub]

365
366
367
368
369
370
371
# File 'app/models/attachment.rb', line 365

def self.latest_attach(attachments, filename)
  return unless filename.valid_encoding?

  attachments.sort_by{|attachment| [attachment.created_on, attachment.id]}.reverse.detect do |att|
    filename.casecmp?(att.filename)
  end
end

.move_from_root_to_target_directoryObject

Moves existing attachments that are stored at the root of the files directory (ie. created before Redmine 2.3) to their target subdirectories

[View source] [View on GitHub]

429
430
431
432
433
# File 'app/models/attachment.rb', line 429

def self.move_from_root_to_target_directory
  Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
    attachment.move_to_target_directory!
  end
end

.prune(age = 1.day) ⇒ Object

[View source] [View on GitHub]

373
374
375
# File 'app/models/attachment.rb', line 373

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

.update_attachments(attachments, params) ⇒ Object

Updates the filename and description of a set of attachments with the given hash of attributes. Returns true if all attachments were updated.

Example:

Attachment.update_attachments(attachments, {
  4 => {:filename => 'foo'},
  7 => {:filename => 'bar', :description => 'file description'}
})
[View source] [View on GitHub]

346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'app/models/attachment.rb', line 346

def self.update_attachments(attachments, params)
  params = params.transform_keys {|key| key.to_i}

  saved = true
  transaction do
    attachments.each do |attachment|
      if p = params[attachment.id]
        attachment.filename = p[:filename] if p.key?(:filename)
        attachment.description = p[:description] if p.key?(:description)
        saved &&= attachment.save
      end
    end
    unless saved
      raise ActiveRecord::Rollback
    end
  end
  saved
end

.update_digests_to_sha256Object

Updates digests to SHA256 for all attachments that have a MD5 digest (ie. created before Redmine 3.4)

[View source] [View on GitHub]

437
438
439
440
441
# File 'app/models/attachment.rb', line 437

def self.update_digests_to_sha256
  Attachment.where("length(digest) < 64").find_each do |attachment|
    attachment.update_digest_to_sha256!
  end
end

.valid_extension?(extension) ⇒ Boolean

Returns true if the extension is allowed regarding allowed/denied extensions defined in application settings, otherwise false

Returns:

  • (Boolean)
[View source] [View on GitHub]

458
459
460
461
462
463
464
465
466
467
468
469
470
# File 'app/models/attachment.rb', line 458

def self.valid_extension?(extension)
  denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
    Setting.send(setting)
  end
  if denied.present? && extension_in?(extension, denied)
    return false
  end
  if allowed.present? && !extension_in?(extension, allowed)
    return false
  end

  true
end

Instance Method Details

#copy(attributes = nil) ⇒ Object

Returns an unsaved copy of the attachment

[View source] [View on GitHub]

93
94
95
96
97
98
# File 'app/models/attachment.rb', line 93

def copy(attributes=nil)
  copy = self.class.new
  copy.attributes = self.attributes.dup.except("id", "downloads")
  copy.attributes = attributes if attributes
  copy
end

#deletable?(user = User.current) ⇒ Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

213
214
215
216
217
218
219
# File 'app/models/attachment.rb', line 213

def deletable?(user=User.current)
  if container_id
    container && container.attachments_deletable?(user)
  else
    author == user
  end
end

#delete_from_diskObject

Deletes the file from the file system if it’s not referenced by other attachments

[View source] [View on GitHub]

170
171
172
173
174
# File 'app/models/attachment.rb', line 170

def delete_from_disk
  if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
    delete_from_disk!
  end
end

#digest_typeObject

returns either MD5 or SHA256 depending on the way self.digest was computed

[View source] [View on GitHub]

489
490
491
# File 'app/models/attachment.rb', line 489

def digest_type
  digest.size < 64 ? "MD5" : "SHA256" if digest.present?
end

#diskfileObject

Returns file’s location on disk

[View source] [View on GitHub]

177
178
179
# File 'app/models/attachment.rb', line 177

def diskfile
  File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
end

#editable?(user = User.current) ⇒ Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

205
206
207
208
209
210
211
# File 'app/models/attachment.rb', line 205

def editable?(user=User.current)
  if container_id
    container && container.attachments_editable?(user)
  else
    author == user
  end
end

#extension_in?(extensions) ⇒ Boolean

Returns true if attachment’s extension belongs to extensions list.

Returns:

  • (Boolean)
[View source] [View on GitHub]

484
485
486
# File 'app/models/attachment.rb', line 484

def extension_in?(extensions)
  self.class.extension_in?(File.extname(filename), extensions)
end

#fileObject

[View source] [View on GitHub]

127
128
129
# File 'app/models/attachment.rb', line 127

def file
  nil
end

#file=(incoming_file) ⇒ Object

[View source] [View on GitHub]

113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'app/models/attachment.rb', line 113

def file=(incoming_file)
  unless incoming_file.nil?
    @temp_file = incoming_file
    if @temp_file.respond_to?(:original_filename)
      self.filename = @temp_file.original_filename
      self.filename.force_encoding("UTF-8")
    end
    if @temp_file.respond_to?(:content_type)
      self.content_type = @temp_file.content_type.to_s.chomp
    end
    self.filesize = @temp_file.size
  end
end

#filename=(arg) ⇒ Object

[View source] [View on GitHub]

131
132
133
134
# File 'app/models/attachment.rb', line 131

def filename=(arg)
  write_attribute :filename, sanitize_filename(arg.to_s)
  filename
end

#files_to_final_locationObject

Copies the temporary file to its final location and computes its MD5 hash

[View source] [View on GitHub]

138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'app/models/attachment.rb', line 138

def files_to_final_location
  if @temp_file
    self.disk_directory = target_directory
    sha = Digest::SHA256.new
    Attachment.create_diskfile(filename, disk_directory) do |f|
      self.disk_filename = File.basename f.path
      logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
      if @temp_file.respond_to?(:read)
        buffer = ""
        while (buffer = @temp_file.read(8192))
          f.write(buffer)
          sha.update(buffer)
        end
      else
        f.write(@temp_file)
        sha.update(@temp_file)
      end
    end
    self.digest = sha.hexdigest
  end
  @temp_file = nil

  if content_type.blank? && filename.present?
    self.content_type = Redmine::MimeType.of(filename)
  end
  # Don't save the content type if it's longer than the authorized length
  if self.content_type && self.content_type.length > 255
    self.content_type = nil
  end
end

#image?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

221
222
223
# File 'app/models/attachment.rb', line 221

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

#increment_downloadObject

[View source] [View on GitHub]

189
190
191
# File 'app/models/attachment.rb', line 189

def increment_download
  increment!(:downloads)
end

#is_audio?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

296
297
298
# File 'app/models/attachment.rb', line 296

def is_audio?
  Redmine::MimeType.is_type?('audio', filename)
end

#is_diff?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

284
285
286
# File 'app/models/attachment.rb', line 284

def is_diff?
  /\.(patch|diff)$/i.match?(filename)
end

#is_image?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

280
281
282
# File 'app/models/attachment.rb', line 280

def is_image?
  Redmine::MimeType.is_type?('image', filename)
end

#is_markdown?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

272
273
274
# File 'app/models/attachment.rb', line 272

def is_markdown?
  Redmine::MimeType.of(filename) == 'text/markdown'
end

#is_pdf?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

288
289
290
# File 'app/models/attachment.rb', line 288

def is_pdf?
  Redmine::MimeType.of(filename) == "application/pdf"
end

#is_text?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

268
269
270
# File 'app/models/attachment.rb', line 268

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

#is_textile?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

276
277
278
# File 'app/models/attachment.rb', line 276

def is_textile?
  Redmine::MimeType.of(filename) == 'text/x-textile'
end

#is_video?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

292
293
294
# File 'app/models/attachment.rb', line 292

def is_video?
  Redmine::MimeType.is_type?('video', filename)
end

#move_to_target_directory!Object

Moves an existing attachment to its target directory

[View source] [View on GitHub]

405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/models/attachment.rb', line 405

def move_to_target_directory!
  return unless !new_record? & readable?

  src = diskfile
  self.disk_directory = target_directory
  dest = diskfile

  return if src == dest

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

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

  update_column :disk_directory, disk_directory
end

#previewable?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

300
301
302
# File 'app/models/attachment.rb', line 300

def previewable?
  is_text? || is_image? || is_video? || is_audio?
end

#projectObject

[View source] [View on GitHub]

193
194
195
# File 'app/models/attachment.rb', line 193

def project
  container.try(:project)
end

#readable?Boolean

Returns true if the file is readable

Returns:

  • (Boolean)
[View source] [View on GitHub]

305
306
307
# File 'app/models/attachment.rb', line 305

def readable?
  disk_filename.present? && File.readable?(diskfile)
end

#thumbnail(options = {}) ⇒ Object

Returns the full path the attachment thumbnail, or nil if the thumbnail cannot be generated.

[View source] [View on GitHub]

233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'app/models/attachment.rb', line 233

def thumbnail(options={})
  if thumbnailable? && readable?
    size = options[:size].to_i
    if size > 0
      # Limit the number of thumbnails per image
      size = (size / 50.0).ceil * 50
      # Maximum thumbnail size
      size = 800 if size > 800
    else
      size = Setting.thumbnails_size.to_i
    end
    size = 100 unless size > 0
    target = thumbnail_path(size)

    begin
      Redmine::Thumbnail.generate(self.diskfile, target, size, is_pdf?)
    rescue => e
      if logger
        logger.error(
          "An error occured while generating thumbnail for #{disk_filename} " \
            "to #{target}\nException was: #{e.message}"
        )
      end
      nil
    end
  end
end

#thumbnailable?Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

225
226
227
228
229
# File 'app/models/attachment.rb', line 225

def thumbnailable?
  Redmine::Thumbnail.convert_available? && (
    image? || (is_pdf? && Redmine::Thumbnail.gs_available?)
  )
end

#titleObject

[View source] [View on GitHub]

181
182
183
184
185
186
187
# File 'app/models/attachment.rb', line 181

def title
  title = filename.dup
  if description.present?
    title << " (#{description})"
  end
  title
end

#tokenObject

Returns the attachment token

[View source] [View on GitHub]

310
311
312
# File 'app/models/attachment.rb', line 310

def token
  "#{id}.#{digest}"
end

#update_digest_to_sha256!Object

Updates attachment digest to SHA256

[View source] [View on GitHub]

444
445
446
447
448
449
450
451
452
453
454
# File 'app/models/attachment.rb', line 444

def update_digest_to_sha256!
  if readable?
    sha = Digest::SHA256.new
    File.open(diskfile, 'rb') do |f|
      while buffer = f.read(8192)
        sha.update(buffer)
      end
    end
    update_column :digest, sha.hexdigest
  end
end

#validate_file_extensionObject

[View source] [View on GitHub]

106
107
108
109
110
111
# File 'app/models/attachment.rb', line 106

def validate_file_extension
  extension = File.extname(filename)
  unless self.class.valid_extension?(extension)
    errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
  end
end

#validate_max_file_sizeObject

[View source] [View on GitHub]

100
101
102
103
104
# File 'app/models/attachment.rb', line 100

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

#visible?(user = User.current) ⇒ Boolean

Returns:

  • (Boolean)
[View source] [View on GitHub]

197
198
199
200
201
202
203
# File 'app/models/attachment.rb', line 197

def visible?(user=User.current)
  if container_id
    container && container.attachments_visible?(user)
  else
    author == user
  end
end