2011-05-15 23:45:13 +00:00
# Redmine - project management software
2017-06-25 08:40:31 +00:00
# Copyright (C) 2006-2017 Jean-Philippe Lang
2007-03-12 17:59:02 +00:00
#
# 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.
2011-05-15 23:45:13 +00:00
#
2007-03-12 17:59:02 +00:00
# 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.
2011-05-15 23:45:13 +00:00
#
2007-03-12 17:59:02 +00:00
# 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.
2017-04-03 11:11:36 +00:00
require " digest "
2012-12-13 12:07:19 +00:00
require " fileutils "
2007-03-12 17:59:02 +00:00
class Attachment < ActiveRecord :: Base
2016-10-01 13:22:35 +00:00
include Redmine :: SafeAttributes
2007-03-12 17:59:02 +00:00
belongs_to :container , :polymorphic = > true
2014-10-22 18:41:03 +00:00
belongs_to :author , :class_name = > " User "
2011-05-15 23:45:13 +00:00
2012-02-16 21:00:11 +00:00
validates_presence_of :filename , :author
2007-07-16 17:16:49 +00:00
validates_length_of :filename , :maximum = > 255
validates_length_of :disk_filename , :maximum = > 255
2012-07-15 14:42:00 +00:00
validates_length_of :description , :maximum = > 255
2015-11-01 10:41:02 +00:00
validate :validate_max_file_size , :validate_file_extension
2007-08-29 16:52:35 +00:00
acts_as_event :title = > :filename ,
2017-06-10 10:30:59 +00:00
:url = > Proc . new { | o | { :controller = > 'attachments' , :action = > 'show' , :id = > o . id , :filename = > o . filename } }
2007-08-29 16:52:35 +00:00
2008-07-27 17:54:09 +00:00
acts_as_activity_provider :type = > 'files' ,
:permission = > :view_files ,
2008-11-30 11:18:22 +00:00
:author_key = > :author_id ,
2014-10-22 17:37:16 +00:00
:scope = > select ( " #{ Attachment . table_name } .* " ) .
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 ) " )
2011-05-15 23:45:13 +00:00
2008-07-27 17:54:09 +00:00
acts_as_activity_provider :type = > 'documents' ,
:permission = > :view_documents ,
2008-11-30 11:18:22 +00:00
:author_key = > :author_id ,
2014-10-22 17:37:16 +00:00
:scope = > 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 " )
2008-07-27 17:54:09 +00:00
2007-03-12 17:59:02 +00:00
cattr_accessor :storage_path
2012-04-28 11:20:13 +00:00
@@storage_path = Redmine :: Configuration [ 'attachments_storage_path' ] || File . join ( Rails . root , " files " )
2011-05-15 23:45:13 +00:00
2012-07-07 13:48:07 +00:00
cattr_accessor :thumbnails_storage_path
@@thumbnails_storage_path = File . join ( Rails . root , " tmp " , " thumbnails " )
2014-12-21 21:02:38 +00:00
before_create :files_to_final_location
2016-01-21 05:38:55 +00:00
after_rollback :delete_from_disk , :on = > :create
2015-09-30 18:59:51 +00:00
after_commit :delete_from_disk , :on = > :destroy
2017-04-03 11:54:29 +00:00
after_commit :reuse_existing_file_if_possible , :on = > :create
2011-08-30 04:11:48 +00:00
2016-10-01 13:22:35 +00:00
safe_attributes 'filename' , 'content_type' , 'description'
2012-01-20 17:56:28 +00:00
# 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
2011-08-29 02:32:18 +00:00
def validate_max_file_size
2012-01-20 17:56:28 +00:00
if @temp_file && self . filesize > Setting . attachment_max_size . to_i . kilobytes
2012-02-23 12:18:10 +00:00
errors . add ( :base , l ( :error_attachment_too_big , :max_size = > Setting . attachment_max_size . to_i . kilobytes ) )
2009-04-08 16:56:01 +00:00
end
2007-03-12 17:59:02 +00:00
end
2015-11-01 10:41:02 +00:00
def validate_file_extension
if @temp_file
extension = File . extname ( filename )
unless self . class . valid_extension? ( extension )
errors . add ( :base , l ( :error_attachment_extension_not_allowed , :extension = > extension ) )
end
end
end
2008-04-02 21:30:32 +00:00
def file = ( incoming_file )
unless incoming_file . nil?
@temp_file = incoming_file
2012-02-23 10:01:16 +00:00
if @temp_file . respond_to? ( :original_filename )
self . filename = @temp_file . original_filename
2014-10-22 17:37:16 +00:00
self . filename . force_encoding ( " UTF-8 " )
2012-02-23 10:01:16 +00:00
end
if @temp_file . respond_to? ( :content_type )
self . content_type = @temp_file . content_type . to_s . chomp
end
2008-04-02 21:30:32 +00:00
self . filesize = @temp_file . size
end
end
2012-02-26 10:16:09 +00:00
2008-04-02 21:30:32 +00:00
def file
nil
end
2012-02-23 10:01:16 +00:00
def filename = ( arg )
write_attribute :filename , sanitize_filename ( arg . to_s )
filename
end
2009-04-10 16:37:42 +00:00
# Copies the temporary file to its final location
# and computes its MD5 hash
2011-08-30 04:11:48 +00:00
def files_to_final_location
2017-02-28 18:05:17 +00:00
if @temp_file
2012-12-13 12:07:19 +00:00
self . disk_directory = target_directory
self . disk_filename = Attachment . disk_filename ( filename , disk_directory )
2013-10-05 11:37:57 +00:00
logger . info ( " Saving attachment ' #{ self . diskfile } ' ( #{ @temp_file . size } bytes) " ) if logger
2012-12-13 12:07:19 +00:00
path = File . dirname ( diskfile )
unless File . directory? ( path )
FileUtils . mkdir_p ( path )
end
2017-04-03 11:11:36 +00:00
sha = Digest :: SHA256 . new
2011-05-15 23:45:13 +00:00
File . open ( diskfile , " wb " ) do | f |
2012-05-05 09:19:23 +00:00
if @temp_file . respond_to? ( :read )
buffer = " "
while ( buffer = @temp_file . read ( 8192 ) )
f . write ( buffer )
2017-04-03 11:11:36 +00:00
sha . update ( buffer )
2012-05-05 09:19:23 +00:00
end
else
f . write ( @temp_file )
2017-04-03 11:11:36 +00:00
sha . update ( @temp_file )
2009-04-10 16:37:42 +00:00
end
2008-04-02 21:30:32 +00:00
end
2017-04-03 11:11:36 +00:00
self . digest = sha . hexdigest
2008-04-02 21:30:32 +00:00
end
2011-07-29 15:28:59 +00:00
@temp_file = nil
2014-12-21 21:02:38 +00:00
if content_type . blank? && filename . present?
self . content_type = Redmine :: MimeType . of ( filename )
end
2008-04-02 21:30:32 +00:00
# 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
2012-01-20 17:56:28 +00:00
# Deletes the file from the file system if it's not referenced by other attachments
2011-08-30 05:08:21 +00:00
def delete_from_disk
2012-05-25 20:54:11 +00:00
if Attachment . where ( " disk_filename = ? AND id <> ? " , disk_filename , id ) . empty?
2012-01-20 17:56:28 +00:00
delete_from_disk!
end
2008-04-02 21:30:32 +00:00
end
# Returns file's location on disk
def diskfile
2012-12-13 12:07:19 +00:00
File . join ( self . class . storage_path , disk_directory . to_s , disk_filename . to_s )
2008-04-02 21:30:32 +00:00
end
2011-05-15 23:45:13 +00:00
2012-07-16 16:42:23 +00:00
def title
title = filename . to_s
if description . present?
title << " ( #{ description } ) "
end
title
end
2007-03-12 17:59:02 +00:00
def increment_download
increment! ( :downloads )
end
2007-05-26 15:42:37 +00:00
def project
2012-02-16 21:00:11 +00:00
container . try ( :project )
2007-05-26 15:42:37 +00:00
end
2011-05-15 23:45:13 +00:00
2008-12-09 16:54:46 +00:00
def visible? ( user = User . current )
2012-12-10 20:09:41 +00:00
if container_id
container && container . attachments_visible? ( user )
else
author == user
end
2008-12-09 16:54:46 +00:00
end
2011-05-15 23:45:13 +00:00
2014-11-29 13:41:53 +00:00
def editable? ( user = User . current )
if container_id
container && container . attachments_editable? ( user )
else
author == user
end
end
2008-12-09 16:54:46 +00:00
def deletable? ( user = User . current )
2012-12-10 20:09:41 +00:00
if container_id
container && container . attachments_deletable? ( user )
else
author == user
end
2008-12-09 16:54:46 +00:00
end
2011-05-15 23:45:13 +00:00
2007-08-15 15:36:15 +00:00
def image?
2012-07-07 13:48:07 +00:00
! ! ( self . filename =~ / \ .(bmp|gif|jpg|jpe|jpeg|png)$ /i )
end
def thumbnailable?
image?
end
# Returns the full path the attachment thumbnail, or nil
# if the thumbnail cannot be generated.
2012-07-16 17:15:40 +00:00
def thumbnail ( options = { } )
2012-07-07 13:48:07 +00:00
if thumbnailable? && readable?
2012-07-16 17:15:40 +00:00
size = options [ :size ] . to_i
if size > 0
# Limit the number of thumbnails per image
size = ( size / 50 ) * 50
# Maximum thumbnail size
size = 800 if size > 800
else
size = Setting . thumbnails_size . to_i
end
2012-07-07 13:48:07 +00:00
size = 100 unless size > 0
target = File . join ( self . class . thumbnails_storage_path , " #{ id } _ #{ digest } _ #{ size } .thumb " )
begin
Redmine :: Thumbnail . generate ( self . diskfile , target , size )
rescue = > e
logger . error " An error occured while generating thumbnail for #{ disk_filename } to #{ target } \n Exception was: #{ e . message } " if logger
return 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
2007-08-15 15:36:15 +00:00
end
2011-05-15 23:45:13 +00:00
2008-06-09 18:40:59 +00:00
def is_text?
Redmine :: MimeType . is_type? ( 'text' , filename )
end
2011-05-15 23:45:13 +00:00
2016-04-11 19:17:48 +00:00
def is_image?
Redmine :: MimeType . is_type? ( 'image' , filename )
end
2008-06-08 18:26:39 +00:00
def is_diff?
self . filename =~ / \ .(patch|diff)$ /i
end
2011-05-15 23:45:13 +00:00
2016-05-09 17:22:23 +00:00
def is_pdf?
Redmine :: MimeType . of ( filename ) == " application/pdf "
end
2017-06-10 10:30:59 +00:00
def previewable?
is_text? || is_image?
end
2009-04-25 09:31:36 +00:00
# Returns true if the file is readable
def readable?
2017-04-03 11:38:06 +00:00
disk_filename . present? && File . readable? ( diskfile )
2009-04-25 09:31:36 +00:00
end
2010-03-02 19:26:03 +00:00
2012-02-16 21:00:11 +00:00
# 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
2012-05-25 20:51:43 +00:00
attachment = Attachment . where ( :id = > attachment_id , :digest = > attachment_digest ) . first
2012-02-16 21:00:11 +00:00
if attachment && attachment . container . nil?
attachment
end
end
end
2010-03-02 19:26:03 +00:00
# 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 )
2012-02-16 21:00:11 +00:00
result = obj . save_attachments ( attachments , User . current )
obj . attach_saved_attachments
result
2010-03-02 19:26:03 +00:00
end
2011-05-15 23:45:13 +00:00
2014-11-29 13:41:53 +00:00
# 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 )
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
2011-11-24 05:31:29 +00:00
def self . latest_attach ( attachments , filename )
2014-10-28 18:48:07 +00:00
attachments . sort_by ( & :created_on ) . reverse . detect do | att |
2015-08-02 07:25:23 +00:00
filename . casecmp ( att . filename ) == 0
2014-10-28 18:48:07 +00:00
end
2011-11-24 05:31:29 +00:00
end
2012-02-16 21:00:11 +00:00
def self . prune ( age = 1 . day )
2012-05-25 20:51:43 +00:00
Attachment . where ( " created_on < ? AND (container_type IS NULL OR container_type = '') " , Time . now - age ) . destroy_all
2012-02-16 21:00:11 +00:00
end
2013-01-26 00:47:29 +00:00
# Moves an existing attachment to its target directory
2012-12-13 12:07:19 +00:00
def move_to_target_directory!
2013-11-09 09:46:55 +00:00
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
2012-12-13 12:07:19 +00:00
end
2013-11-09 09:46:55 +00:00
update_column :disk_directory , disk_directory
2012-12-13 12:07:19 +00:00
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
2017-04-03 11:38:06 +00:00
# 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
2016-10-23 11:00:02 +00:00
# Returns true if the extension is allowed regarding allowed/denied
# extensions defined in application settings, otherwise false
2015-11-01 10:41:02 +00:00
def self . valid_extension? ( extension )
denied , allowed = [ :attachment_extensions_denied , :attachment_extensions_allowed ] . map do | setting |
2016-10-23 11:00:02 +00:00
Setting . send ( setting )
2015-11-01 10:41:02 +00:00
end
2016-10-23 11:00:02 +00:00
if denied . present? && extension_in? ( extension , denied )
2015-11-01 10:41:02 +00:00
return false
end
2016-10-23 11:00:02 +00:00
if allowed . present? && ! extension_in? ( extension , allowed )
2015-11-01 10:41:02 +00:00
return false
end
true
end
2016-10-23 11:00:02 +00:00
# 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
2017-04-03 11:41:52 +00:00
# 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
2012-01-20 17:56:28 +00:00
private
2017-04-03 11:54:29 +00:00
def reuse_existing_file_if_possible
2017-04-03 12:13:07 +00:00
original_diskfile = nil
reused = with_lock do
2017-04-03 11:54:29 +00:00
if existing = Attachment
. where ( digest : self . digest , filesize : self . filesize )
. where ( 'id <> ? and disk_filename <> ?' ,
self . id , self . disk_filename )
. first
2017-04-13 12:10:03 +00:00
existing . with_lock do
2017-04-03 11:54:29 +00:00
2017-04-13 12:10:03 +00:00
original_diskfile = self . diskfile
existing_diskfile = existing . diskfile
2017-04-03 11:55:40 +00:00
2017-04-13 12:10:03 +00:00
if File . readable? ( original_diskfile ) &&
File . readable? ( existing_diskfile ) &&
FileUtils . identical? ( original_diskfile , existing_diskfile )
2017-04-03 11:55:40 +00:00
2017-04-13 12:10:03 +00:00
self . update_columns disk_directory : existing . disk_directory ,
disk_filename : existing . disk_filename
end
2017-04-03 11:55:40 +00:00
end
2017-04-03 11:54:29 +00:00
end
end
2017-04-03 12:13:07 +00:00
if reused
File . delete ( original_diskfile )
end
2017-04-13 12:10:03 +00:00
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.
2017-04-03 11:54:29 +00:00
end
2012-01-20 17:56:28 +00:00
# Physically deletes the file from the file system
def delete_from_disk!
if disk_filename . present? && File . exist? ( diskfile )
File . delete ( diskfile )
end
end
2007-03-12 17:59:02 +00:00
def sanitize_filename ( value )
2008-04-02 21:30:32 +00:00
# get only the filename, not the whole path
2013-09-11 19:19:24 +00:00
just_filename = value . gsub ( / \ A.*( \\ | \/ ) /m , '' )
2007-03-12 17:59:02 +00:00
2011-11-24 20:17:56 +00:00
# Finally, replace invalid characters with underscore
2014-10-28 18:50:25 +00:00
just_filename . gsub ( / [ \/ \ ? \ % \ * \ : \ | \ " \ '<> \ n \ r]+ / , '_' )
2007-03-12 17:59:02 +00:00
end
2011-05-15 23:45:13 +00:00
2012-12-13 12:07:19 +00:00
# Returns the subdirectory in which the attachment will be saved
def target_directory
time = created_on || DateTime . now
time . strftime ( " %Y/%m " )
end
# Returns an ASCII or hashed filename that do not
# exists yet in the given subdirectory
def self . disk_filename ( filename , directory = nil )
2010-02-28 11:12:40 +00:00
timestamp = DateTime . now . strftime ( " %y%m%d%H%M%S " )
ascii = ''
2016-12-16 08:45:41 +00:00
if filename =~ %r{ ^[a-zA-Z0-9_ \ . \ -]*$ } && filename . length < = 50
2010-02-28 11:12:40 +00:00
ascii = filename
2008-05-17 11:03:43 +00:00
else
2010-02-28 11:12:40 +00:00
ascii = Digest :: MD5 . hexdigest ( filename )
2008-05-17 11:03:43 +00:00
# keep the extension if any
2010-02-28 11:12:40 +00:00
ascii << $1 if filename =~ %r{ ( \ .[a-zA-Z0-9]+)$ }
2008-05-17 11:03:43 +00:00
end
2012-12-13 12:07:19 +00:00
while File . exist? ( File . join ( storage_path , directory . to_s , " #{ timestamp } _ #{ ascii } " ) )
2010-02-28 11:12:40 +00:00
timestamp . succ!
end
" #{ timestamp } _ #{ ascii } "
2008-05-17 11:03:43 +00:00
end
2006-06-28 18:11:03 +00:00
end