##// END OF EJS Templates
Uploading of attachments which filename contains non-ASCII chars fails with Ruby 1.9 on issue update (#10575)....
Jean-Philippe Lang -
r9200:b4975862d66c
parent child
Show More
@@ -1,243 +1,244
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/md5"
18 require "digest/md5"
19
19
20 class Attachment < ActiveRecord::Base
20 class Attachment < ActiveRecord::Base
21 belongs_to :container, :polymorphic => true
21 belongs_to :container, :polymorphic => true
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23
23
24 validates_presence_of :filename, :author
24 validates_presence_of :filename, :author
25 validates_length_of :filename, :maximum => 255
25 validates_length_of :filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
27 validate :validate_max_file_size
27 validate :validate_max_file_size
28
28
29 acts_as_event :title => :filename,
29 acts_as_event :title => :filename,
30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
31
31
32 acts_as_activity_provider :type => 'files',
32 acts_as_activity_provider :type => 'files',
33 :permission => :view_files,
33 :permission => :view_files,
34 :author_key => :author_id,
34 :author_key => :author_id,
35 :find_options => {:select => "#{Attachment.table_name}.*",
35 :find_options => {:select => "#{Attachment.table_name}.*",
36 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
36 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
37 "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 )"}
37 "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 )"}
38
38
39 acts_as_activity_provider :type => 'documents',
39 acts_as_activity_provider :type => 'documents',
40 :permission => :view_documents,
40 :permission => :view_documents,
41 :author_key => :author_id,
41 :author_key => :author_id,
42 :find_options => {:select => "#{Attachment.table_name}.*",
42 :find_options => {:select => "#{Attachment.table_name}.*",
43 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
43 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
44 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
44 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
45
45
46 cattr_accessor :storage_path
46 cattr_accessor :storage_path
47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{Rails.root}/files"
47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{Rails.root}/files"
48
48
49 before_save :files_to_final_location
49 before_save :files_to_final_location
50 after_destroy :delete_from_disk
50 after_destroy :delete_from_disk
51
51
52 def container_with_blank_type_check
52 def container_with_blank_type_check
53 if container_type.blank?
53 if container_type.blank?
54 nil
54 nil
55 else
55 else
56 container_without_blank_type_check
56 container_without_blank_type_check
57 end
57 end
58 end
58 end
59 alias_method_chain :container, :blank_type_check unless method_defined?(:container_without_blank_type_check)
59 alias_method_chain :container, :blank_type_check unless method_defined?(:container_without_blank_type_check)
60
60
61 # Returns an unsaved copy of the attachment
61 # Returns an unsaved copy of the attachment
62 def copy(attributes=nil)
62 def copy(attributes=nil)
63 copy = self.class.new
63 copy = self.class.new
64 copy.attributes = self.attributes.dup.except("id", "downloads")
64 copy.attributes = self.attributes.dup.except("id", "downloads")
65 copy.attributes = attributes if attributes
65 copy.attributes = attributes if attributes
66 copy
66 copy
67 end
67 end
68
68
69 def validate_max_file_size
69 def validate_max_file_size
70 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
70 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
71 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
71 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
72 end
72 end
73 end
73 end
74
74
75 def file=(incoming_file)
75 def file=(incoming_file)
76 unless incoming_file.nil?
76 unless incoming_file.nil?
77 @temp_file = incoming_file
77 @temp_file = incoming_file
78 if @temp_file.size > 0
78 if @temp_file.size > 0
79 if @temp_file.respond_to?(:original_filename)
79 if @temp_file.respond_to?(:original_filename)
80 self.filename = @temp_file.original_filename
80 self.filename = @temp_file.original_filename
81 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
81 end
82 end
82 if @temp_file.respond_to?(:content_type)
83 if @temp_file.respond_to?(:content_type)
83 self.content_type = @temp_file.content_type.to_s.chomp
84 self.content_type = @temp_file.content_type.to_s.chomp
84 end
85 end
85 if content_type.blank? && filename.present?
86 if content_type.blank? && filename.present?
86 self.content_type = Redmine::MimeType.of(filename)
87 self.content_type = Redmine::MimeType.of(filename)
87 end
88 end
88 self.filesize = @temp_file.size
89 self.filesize = @temp_file.size
89 end
90 end
90 end
91 end
91 end
92 end
92
93
93 def file
94 def file
94 nil
95 nil
95 end
96 end
96
97
97 def filename=(arg)
98 def filename=(arg)
98 write_attribute :filename, sanitize_filename(arg.to_s)
99 write_attribute :filename, sanitize_filename(arg.to_s)
99 if new_record? && disk_filename.blank?
100 if new_record? && disk_filename.blank?
100 self.disk_filename = Attachment.disk_filename(filename)
101 self.disk_filename = Attachment.disk_filename(filename)
101 end
102 end
102 filename
103 filename
103 end
104 end
104
105
105 # Copies the temporary file to its final location
106 # Copies the temporary file to its final location
106 # and computes its MD5 hash
107 # and computes its MD5 hash
107 def files_to_final_location
108 def files_to_final_location
108 if @temp_file && (@temp_file.size > 0)
109 if @temp_file && (@temp_file.size > 0)
109 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
110 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
110 md5 = Digest::MD5.new
111 md5 = Digest::MD5.new
111 File.open(diskfile, "wb") do |f|
112 File.open(diskfile, "wb") do |f|
112 buffer = ""
113 buffer = ""
113 while (buffer = @temp_file.read(8192))
114 while (buffer = @temp_file.read(8192))
114 f.write(buffer)
115 f.write(buffer)
115 md5.update(buffer)
116 md5.update(buffer)
116 end
117 end
117 end
118 end
118 self.digest = md5.hexdigest
119 self.digest = md5.hexdigest
119 end
120 end
120 @temp_file = nil
121 @temp_file = nil
121 # Don't save the content type if it's longer than the authorized length
122 # Don't save the content type if it's longer than the authorized length
122 if self.content_type && self.content_type.length > 255
123 if self.content_type && self.content_type.length > 255
123 self.content_type = nil
124 self.content_type = nil
124 end
125 end
125 end
126 end
126
127
127 # Deletes the file from the file system if it's not referenced by other attachments
128 # Deletes the file from the file system if it's not referenced by other attachments
128 def delete_from_disk
129 def delete_from_disk
129 if Attachment.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil?
130 if Attachment.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil?
130 delete_from_disk!
131 delete_from_disk!
131 end
132 end
132 end
133 end
133
134
134 # Returns file's location on disk
135 # Returns file's location on disk
135 def diskfile
136 def diskfile
136 "#{@@storage_path}/#{self.disk_filename}"
137 "#{@@storage_path}/#{self.disk_filename}"
137 end
138 end
138
139
139 def increment_download
140 def increment_download
140 increment!(:downloads)
141 increment!(:downloads)
141 end
142 end
142
143
143 def project
144 def project
144 container.try(:project)
145 container.try(:project)
145 end
146 end
146
147
147 def visible?(user=User.current)
148 def visible?(user=User.current)
148 container && container.attachments_visible?(user)
149 container && container.attachments_visible?(user)
149 end
150 end
150
151
151 def deletable?(user=User.current)
152 def deletable?(user=User.current)
152 container && container.attachments_deletable?(user)
153 container && container.attachments_deletable?(user)
153 end
154 end
154
155
155 def image?
156 def image?
156 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
157 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
157 end
158 end
158
159
159 def is_text?
160 def is_text?
160 Redmine::MimeType.is_type?('text', filename)
161 Redmine::MimeType.is_type?('text', filename)
161 end
162 end
162
163
163 def is_diff?
164 def is_diff?
164 self.filename =~ /\.(patch|diff)$/i
165 self.filename =~ /\.(patch|diff)$/i
165 end
166 end
166
167
167 # Returns true if the file is readable
168 # Returns true if the file is readable
168 def readable?
169 def readable?
169 File.readable?(diskfile)
170 File.readable?(diskfile)
170 end
171 end
171
172
172 # Returns the attachment token
173 # Returns the attachment token
173 def token
174 def token
174 "#{id}.#{digest}"
175 "#{id}.#{digest}"
175 end
176 end
176
177
177 # Finds an attachment that matches the given token and that has no container
178 # Finds an attachment that matches the given token and that has no container
178 def self.find_by_token(token)
179 def self.find_by_token(token)
179 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
180 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
180 attachment_id, attachment_digest = $1, $2
181 attachment_id, attachment_digest = $1, $2
181 attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest})
182 attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest})
182 if attachment && attachment.container.nil?
183 if attachment && attachment.container.nil?
183 attachment
184 attachment
184 end
185 end
185 end
186 end
186 end
187 end
187
188
188 # Bulk attaches a set of files to an object
189 # Bulk attaches a set of files to an object
189 #
190 #
190 # Returns a Hash of the results:
191 # Returns a Hash of the results:
191 # :files => array of the attached files
192 # :files => array of the attached files
192 # :unsaved => array of the files that could not be attached
193 # :unsaved => array of the files that could not be attached
193 def self.attach_files(obj, attachments)
194 def self.attach_files(obj, attachments)
194 result = obj.save_attachments(attachments, User.current)
195 result = obj.save_attachments(attachments, User.current)
195 obj.attach_saved_attachments
196 obj.attach_saved_attachments
196 result
197 result
197 end
198 end
198
199
199 def self.latest_attach(attachments, filename)
200 def self.latest_attach(attachments, filename)
200 attachments.sort_by(&:created_on).reverse.detect {
201 attachments.sort_by(&:created_on).reverse.detect {
201 |att| att.filename.downcase == filename.downcase
202 |att| att.filename.downcase == filename.downcase
202 }
203 }
203 end
204 end
204
205
205 def self.prune(age=1.day)
206 def self.prune(age=1.day)
206 attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age])
207 attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age])
207 attachments.each(&:destroy)
208 attachments.each(&:destroy)
208 end
209 end
209
210
210 private
211 private
211
212
212 # Physically deletes the file from the file system
213 # Physically deletes the file from the file system
213 def delete_from_disk!
214 def delete_from_disk!
214 if disk_filename.present? && File.exist?(diskfile)
215 if disk_filename.present? && File.exist?(diskfile)
215 File.delete(diskfile)
216 File.delete(diskfile)
216 end
217 end
217 end
218 end
218
219
219 def sanitize_filename(value)
220 def sanitize_filename(value)
220 # get only the filename, not the whole path
221 # get only the filename, not the whole path
221 just_filename = value.gsub(/^.*(\\|\/)/, '')
222 just_filename = value.gsub(/^.*(\\|\/)/, '')
222
223
223 # Finally, replace invalid characters with underscore
224 # Finally, replace invalid characters with underscore
224 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
225 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
225 end
226 end
226
227
227 # Returns an ASCII or hashed filename
228 # Returns an ASCII or hashed filename
228 def self.disk_filename(filename)
229 def self.disk_filename(filename)
229 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
230 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
230 ascii = ''
231 ascii = ''
231 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
232 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
232 ascii = filename
233 ascii = filename
233 else
234 else
234 ascii = Digest::MD5.hexdigest(filename)
235 ascii = Digest::MD5.hexdigest(filename)
235 # keep the extension if any
236 # keep the extension if any
236 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
237 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
237 end
238 end
238 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
239 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
239 timestamp.succ!
240 timestamp.succ!
240 end
241 end
241 "#{timestamp}_#{ascii}"
242 "#{timestamp}_#{ascii}"
242 end
243 end
243 end
244 end
General Comments 0
You need to be logged in to leave comments. Login now