app/models/attachment.rb
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006- Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
require "digest"
require "fileutils"
require "zip"
class Attachment < ApplicationRecord
include Redmine::SafeAttributes
belongs_to :container, :polymorphic => true
belongs_to :author, :class_name => "User"
validates_presence_of :filename, :author
validates_length_of :filename, :maximum => 255
validates_length_of :disk_filename, :maximum => 255
validates_length_of :description, :maximum => 255
validate :validate_max_file_size
validate :validate_file_extension, :if => :filename_changed?
acts_as_event(
:title => :filename,
:url =>
Proc.new do |o|
{:controller => 'attachments', :action => 'show',
:id => o.id, :filename => o.filename}
end
)
acts_as_activity_provider(
:type => 'files',
:permission => :view_files,
:author_key => :author_id,
:scope =>
proc do
select("#{Attachment.table_name}.*").
where(container_type: ['Version', 'Project']).
joins(
"LEFT JOIN #{Version.table_name} " \
"ON #{Attachment.table_name}.container_type='Version' " \
"AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " \
"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 )"
)
end
)
acts_as_activity_provider(
:type => 'documents',
:permission => :view_documents,
:author_key => :author_id,
:scope =>
proc do
select("#{Attachment.table_name}.*").
joins(
"LEFT JOIN #{Document.table_name} " \
"ON #{Attachment.table_name}.container_type='Document' " \
"AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " \
"LEFT JOIN #{Project.table_name} " \
"ON #{Document.table_name}.project_id = #{Project.table_name}.id"
)
end
)
cattr_accessor :storage_path
@@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
cattr_accessor :thumbnails_storage_path
@@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
before_create :files_to_final_location
after_rollback :delete_from_disk, :on => :create
after_commit :delete_from_disk, :on => :destroy
after_commit :reuse_existing_file_if_possible, :on => :create
safe_attributes 'filename', 'content_type', 'description'
# Returns an unsaved copy of the attachment
def copy(attributes=nil)
copy = self.class.new
copy.attributes = self.attributes.dup.except("id", "downloads")
copy.attributes = attributes if attributes
copy
end
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
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
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
def file
nil
end
def filename=(arg)
write_attribute :filename, sanitize_filename(arg.to_s)
filename
end
# Copies the temporary file to its final location
# and computes its hash
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
# Deletes the file from the file system if it's not referenced by other attachments
def delete_from_disk
if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
delete_from_disk!
end
end
# Returns file's location on disk
def diskfile
File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
end
def title
title = filename.dup
if description.present?
title << " (#{description})"
end
title
end
def increment_download
increment!(:downloads)
end
def project
container.try(:project)
end
def visible?(user=User.current)
if container_id
container && container.attachments_visible?(user)
else
author == user
end
end
def editable?(user=User.current)
if container_id
container && container.attachments_editable?(user)
else
author == user
end
end
def deletable?(user=User.current)
if container_id
container && container.attachments_deletable?(user)
else
author == user
end
end
def image?
!!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png|webp)$/i)
end
def thumbnailable?
Redmine::Thumbnail.convert_available? && (
image? || (is_pdf? && Redmine::Thumbnail.gs_available?)
)
end
# Returns the full path the attachment thumbnail, or nil
# if the thumbnail cannot be generated.
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
# Deletes all thumbnails
def self.clear_thumbnails
Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
File.delete file
end
end
def is_text?
Redmine::MimeType.is_type?('text', filename) || Redmine::SyntaxHighlighting.filename_supported?(filename)
end
def is_markdown?
Redmine::MimeType.of(filename) == 'text/markdown'
end
def is_textile?
Redmine::MimeType.of(filename) == 'text/x-textile'
end
def is_image?
Redmine::MimeType.is_type?('image', filename)
end
def is_diff?
/\.(patch|diff)$/i.match?(filename)
end
def is_pdf?
Redmine::MimeType.of(filename) == "application/pdf"
end
def is_video?
Redmine::MimeType.is_type?('video', filename)
end
def is_audio?
Redmine::MimeType.is_type?('audio', filename)
end
def previewable?
is_text? || is_image? || is_video? || is_audio?
end
# Returns true if the file is readable
def readable?
disk_filename.present? && File.readable?(diskfile)
end
# Returns the attachment token
def token
"#{id}.#{digest}"
end
# Finds an attachment that matches the given token and that has no container
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
# 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
def self.attach_files(obj, attachments)
result = obj.save_attachments(attachments, User.current)
obj.attach_saved_attachments
result
end
# 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'}
# })
#
def self.update_attachments(attachments, params)
converted = {}
params.each {|key, val| converted[key.to_i] = val}
saved = true
transaction do
attachments.each do |attachment|
if file = converted[attachment.id]
attachment.filename = file[:filename] if file.key?(:filename)
attachment.description = file[:description] if file.key?(:description)
saved &&= attachment.save
end
end
unless saved
raise ActiveRecord::Rollback
end
end
saved
end
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
def self.prune(age=1.day)
Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
end
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
# Moves an existing attachment to its target directory
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
# Moves existing attachments that are stored at the root of the files
# directory (ie. created before Redmine 2.3) to their target subdirectories
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
# Updates digests to SHA256 for all attachments that have a MD5 digest
# (ie. created before Redmine 3.4)
def self.update_digests_to_sha256
Attachment.where("length(digest) < 64").find_each do |attachment|
attachment.update_digest_to_sha256!
end
end
# Updates attachment digest to SHA256
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
# Returns true if the extension is allowed regarding allowed/denied
# extensions defined in application settings, otherwise false
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
# Returns true if extension belongs to extensions list.
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
# Returns true if attachment's extension belongs to extensions list.
def extension_in?(extensions)
self.class.extension_in?(File.extname(filename), extensions)
end
# returns either MD5 or SHA256 depending on the way self.digest was computed
def digest_type
digest.size < 64 ? "MD5" : "SHA256" if digest.present?
end
private
def reuse_existing_file_if_possible
original_diskfile = diskfile
original_filename = disk_filename
reused = with_lock do
if existing = Attachment
.where(digest: self.digest, filesize: self.filesize)
.where.not(disk_filename: original_filename)
.order(:id)
.last
existing.with_lock do
existing_diskfile = existing.diskfile
if File.readable?(original_diskfile) &&
File.readable?(existing_diskfile) &&
FileUtils.identical?(original_diskfile, existing_diskfile)
self.update_columns disk_directory: existing.disk_directory,
disk_filename: existing.disk_filename
end
end
end
end
if reused && Attachment.where(disk_filename: original_filename).none?
File.delete(original_diskfile)
end
rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound
# Catch and ignore lock errors. It is not critical if deduplication does
# not happen, therefore we do not retry.
# with_lock throws ActiveRecord::RecordNotFound if the record isnt there
# anymore, thats why this is caught and ignored as well.
end
# Physically deletes the file from the file system
def delete_from_disk!
FileUtils.rm_f(diskfile) if disk_filename.present?
Dir[thumbnail_path("*")].each do |thumb|
File.delete(thumb)
end
end
def thumbnail_path(size)
File.join(self.class.thumbnails_storage_path,
"#{digest}_#{filesize}_#{size}.thumb")
end
def sanitize_filename(value)
# get only the filename, not the whole path
just_filename = value.gsub(/\A.*(\\|\/)/m, '')
# Finally, replace invalid characters with underscore
just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
end
# Returns the subdirectory in which the attachment will be saved
def target_directory
time = created_on || DateTime.now
time.strftime("%Y/%m")
end
# Singleton class method is public
class << self
# Claims a unique ASCII or hashed filename, yields the open file handle
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 = ActiveSupport::Digest.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
end
end