##// END OF EJS Templates
Code cleanup....
Jean-Philippe Lang -
r13141:78f65df8f798
parent child
Show More
@@ -1,338 +1,338
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 require "fileutils"
19 require "fileutils"
20
20
21 class Attachment < ActiveRecord::Base
21 class Attachment < ActiveRecord::Base
22 belongs_to :container, :polymorphic => true
22 belongs_to :container, :polymorphic => true
23 belongs_to :author, :class_name => "User"
23 belongs_to :author, :class_name => "User"
24
24
25 validates_presence_of :filename, :author
25 validates_presence_of :filename, :author
26 validates_length_of :filename, :maximum => 255
26 validates_length_of :filename, :maximum => 255
27 validates_length_of :disk_filename, :maximum => 255
27 validates_length_of :disk_filename, :maximum => 255
28 validates_length_of :description, :maximum => 255
28 validates_length_of :description, :maximum => 255
29 validate :validate_max_file_size
29 validate :validate_max_file_size
30 attr_protected :id
30 attr_protected :id
31
31
32 acts_as_event :title => :filename,
32 acts_as_event :title => :filename,
33 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
33 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
34
34
35 acts_as_activity_provider :type => 'files',
35 acts_as_activity_provider :type => 'files',
36 :permission => :view_files,
36 :permission => :view_files,
37 :author_key => :author_id,
37 :author_key => :author_id,
38 :scope => select("#{Attachment.table_name}.*").
38 :scope => select("#{Attachment.table_name}.*").
39 joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
39 joins("LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
40 "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 )")
40 "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 )")
41
41
42 acts_as_activity_provider :type => 'documents',
42 acts_as_activity_provider :type => 'documents',
43 :permission => :view_documents,
43 :permission => :view_documents,
44 :author_key => :author_id,
44 :author_key => :author_id,
45 :scope => select("#{Attachment.table_name}.*").
45 :scope => select("#{Attachment.table_name}.*").
46 joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
46 joins("LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
47 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
47 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id")
48
48
49 cattr_accessor :storage_path
49 cattr_accessor :storage_path
50 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
50 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
51
51
52 cattr_accessor :thumbnails_storage_path
52 cattr_accessor :thumbnails_storage_path
53 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
53 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
54
54
55 before_save :files_to_final_location
55 before_save :files_to_final_location
56 after_destroy :delete_from_disk
56 after_destroy :delete_from_disk
57
57
58 # Returns an unsaved copy of the attachment
58 # Returns an unsaved copy of the attachment
59 def copy(attributes=nil)
59 def copy(attributes=nil)
60 copy = self.class.new
60 copy = self.class.new
61 copy.attributes = self.attributes.dup.except("id", "downloads")
61 copy.attributes = self.attributes.dup.except("id", "downloads")
62 copy.attributes = attributes if attributes
62 copy.attributes = attributes if attributes
63 copy
63 copy
64 end
64 end
65
65
66 def validate_max_file_size
66 def validate_max_file_size
67 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
67 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
68 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
68 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
69 end
69 end
70 end
70 end
71
71
72 def file=(incoming_file)
72 def file=(incoming_file)
73 unless incoming_file.nil?
73 unless incoming_file.nil?
74 @temp_file = incoming_file
74 @temp_file = incoming_file
75 if @temp_file.size > 0
75 if @temp_file.size > 0
76 if @temp_file.respond_to?(:original_filename)
76 if @temp_file.respond_to?(:original_filename)
77 self.filename = @temp_file.original_filename
77 self.filename = @temp_file.original_filename
78 self.filename.force_encoding("UTF-8")
78 self.filename.force_encoding("UTF-8")
79 end
79 end
80 if @temp_file.respond_to?(:content_type)
80 if @temp_file.respond_to?(:content_type)
81 self.content_type = @temp_file.content_type.to_s.chomp
81 self.content_type = @temp_file.content_type.to_s.chomp
82 end
82 end
83 if content_type.blank? && filename.present?
83 if content_type.blank? && filename.present?
84 self.content_type = Redmine::MimeType.of(filename)
84 self.content_type = Redmine::MimeType.of(filename)
85 end
85 end
86 self.filesize = @temp_file.size
86 self.filesize = @temp_file.size
87 end
87 end
88 end
88 end
89 end
89 end
90
90
91 def file
91 def file
92 nil
92 nil
93 end
93 end
94
94
95 def filename=(arg)
95 def filename=(arg)
96 write_attribute :filename, sanitize_filename(arg.to_s)
96 write_attribute :filename, sanitize_filename(arg.to_s)
97 filename
97 filename
98 end
98 end
99
99
100 # Copies the temporary file to its final location
100 # Copies the temporary file to its final location
101 # and computes its MD5 hash
101 # and computes its MD5 hash
102 def files_to_final_location
102 def files_to_final_location
103 if @temp_file && (@temp_file.size > 0)
103 if @temp_file && (@temp_file.size > 0)
104 self.disk_directory = target_directory
104 self.disk_directory = target_directory
105 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
105 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
106 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
106 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
107 path = File.dirname(diskfile)
107 path = File.dirname(diskfile)
108 unless File.directory?(path)
108 unless File.directory?(path)
109 FileUtils.mkdir_p(path)
109 FileUtils.mkdir_p(path)
110 end
110 end
111 md5 = Digest::MD5.new
111 md5 = Digest::MD5.new
112 File.open(diskfile, "wb") do |f|
112 File.open(diskfile, "wb") do |f|
113 if @temp_file.respond_to?(:read)
113 if @temp_file.respond_to?(:read)
114 buffer = ""
114 buffer = ""
115 while (buffer = @temp_file.read(8192))
115 while (buffer = @temp_file.read(8192))
116 f.write(buffer)
116 f.write(buffer)
117 md5.update(buffer)
117 md5.update(buffer)
118 end
118 end
119 else
119 else
120 f.write(@temp_file)
120 f.write(@temp_file)
121 md5.update(@temp_file)
121 md5.update(@temp_file)
122 end
122 end
123 end
123 end
124 self.digest = md5.hexdigest
124 self.digest = md5.hexdigest
125 end
125 end
126 @temp_file = nil
126 @temp_file = nil
127 # Don't save the content type if it's longer than the authorized length
127 # Don't save the content type if it's longer than the authorized length
128 if self.content_type && self.content_type.length > 255
128 if self.content_type && self.content_type.length > 255
129 self.content_type = nil
129 self.content_type = nil
130 end
130 end
131 end
131 end
132
132
133 # Deletes the file from the file system if it's not referenced by other attachments
133 # Deletes the file from the file system if it's not referenced by other attachments
134 def delete_from_disk
134 def delete_from_disk
135 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
135 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
136 delete_from_disk!
136 delete_from_disk!
137 end
137 end
138 end
138 end
139
139
140 # Returns file's location on disk
140 # Returns file's location on disk
141 def diskfile
141 def diskfile
142 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
142 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
143 end
143 end
144
144
145 def title
145 def title
146 title = filename.to_s
146 title = filename.to_s
147 if description.present?
147 if description.present?
148 title << " (#{description})"
148 title << " (#{description})"
149 end
149 end
150 title
150 title
151 end
151 end
152
152
153 def increment_download
153 def increment_download
154 increment!(:downloads)
154 increment!(:downloads)
155 end
155 end
156
156
157 def project
157 def project
158 container.try(:project)
158 container.try(:project)
159 end
159 end
160
160
161 def visible?(user=User.current)
161 def visible?(user=User.current)
162 if container_id
162 if container_id
163 container && container.attachments_visible?(user)
163 container && container.attachments_visible?(user)
164 else
164 else
165 author == user
165 author == user
166 end
166 end
167 end
167 end
168
168
169 def deletable?(user=User.current)
169 def deletable?(user=User.current)
170 if container_id
170 if container_id
171 container && container.attachments_deletable?(user)
171 container && container.attachments_deletable?(user)
172 else
172 else
173 author == user
173 author == user
174 end
174 end
175 end
175 end
176
176
177 def image?
177 def image?
178 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
178 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
179 end
179 end
180
180
181 def thumbnailable?
181 def thumbnailable?
182 image?
182 image?
183 end
183 end
184
184
185 # Returns the full path the attachment thumbnail, or nil
185 # Returns the full path the attachment thumbnail, or nil
186 # if the thumbnail cannot be generated.
186 # if the thumbnail cannot be generated.
187 def thumbnail(options={})
187 def thumbnail(options={})
188 if thumbnailable? && readable?
188 if thumbnailable? && readable?
189 size = options[:size].to_i
189 size = options[:size].to_i
190 if size > 0
190 if size > 0
191 # Limit the number of thumbnails per image
191 # Limit the number of thumbnails per image
192 size = (size / 50) * 50
192 size = (size / 50) * 50
193 # Maximum thumbnail size
193 # Maximum thumbnail size
194 size = 800 if size > 800
194 size = 800 if size > 800
195 else
195 else
196 size = Setting.thumbnails_size.to_i
196 size = Setting.thumbnails_size.to_i
197 end
197 end
198 size = 100 unless size > 0
198 size = 100 unless size > 0
199 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
199 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
200
200
201 begin
201 begin
202 Redmine::Thumbnail.generate(self.diskfile, target, size)
202 Redmine::Thumbnail.generate(self.diskfile, target, size)
203 rescue => e
203 rescue => e
204 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
204 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
205 return nil
205 return nil
206 end
206 end
207 end
207 end
208 end
208 end
209
209
210 # Deletes all thumbnails
210 # Deletes all thumbnails
211 def self.clear_thumbnails
211 def self.clear_thumbnails
212 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
212 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
213 File.delete file
213 File.delete file
214 end
214 end
215 end
215 end
216
216
217 def is_text?
217 def is_text?
218 Redmine::MimeType.is_type?('text', filename)
218 Redmine::MimeType.is_type?('text', filename)
219 end
219 end
220
220
221 def is_diff?
221 def is_diff?
222 self.filename =~ /\.(patch|diff)$/i
222 self.filename =~ /\.(patch|diff)$/i
223 end
223 end
224
224
225 # Returns true if the file is readable
225 # Returns true if the file is readable
226 def readable?
226 def readable?
227 File.readable?(diskfile)
227 File.readable?(diskfile)
228 end
228 end
229
229
230 # Returns the attachment token
230 # Returns the attachment token
231 def token
231 def token
232 "#{id}.#{digest}"
232 "#{id}.#{digest}"
233 end
233 end
234
234
235 # Finds an attachment that matches the given token and that has no container
235 # Finds an attachment that matches the given token and that has no container
236 def self.find_by_token(token)
236 def self.find_by_token(token)
237 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
237 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
238 attachment_id, attachment_digest = $1, $2
238 attachment_id, attachment_digest = $1, $2
239 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
239 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
240 if attachment && attachment.container.nil?
240 if attachment && attachment.container.nil?
241 attachment
241 attachment
242 end
242 end
243 end
243 end
244 end
244 end
245
245
246 # Bulk attaches a set of files to an object
246 # Bulk attaches a set of files to an object
247 #
247 #
248 # Returns a Hash of the results:
248 # Returns a Hash of the results:
249 # :files => array of the attached files
249 # :files => array of the attached files
250 # :unsaved => array of the files that could not be attached
250 # :unsaved => array of the files that could not be attached
251 def self.attach_files(obj, attachments)
251 def self.attach_files(obj, attachments)
252 result = obj.save_attachments(attachments, User.current)
252 result = obj.save_attachments(attachments, User.current)
253 obj.attach_saved_attachments
253 obj.attach_saved_attachments
254 result
254 result
255 end
255 end
256
256
257 def self.latest_attach(attachments, filename)
257 def self.latest_attach(attachments, filename)
258 attachments.sort_by(&:created_on).reverse.detect {
258 attachments.sort_by(&:created_on).reverse.detect do |att|
259 |att| att.filename.downcase == filename.downcase
259 att.filename.downcase == filename.downcase
260 }
260 end
261 end
261 end
262
262
263 def self.prune(age=1.day)
263 def self.prune(age=1.day)
264 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
264 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
265 end
265 end
266
266
267 # Moves an existing attachment to its target directory
267 # Moves an existing attachment to its target directory
268 def move_to_target_directory!
268 def move_to_target_directory!
269 return unless !new_record? & readable?
269 return unless !new_record? & readable?
270
270
271 src = diskfile
271 src = diskfile
272 self.disk_directory = target_directory
272 self.disk_directory = target_directory
273 dest = diskfile
273 dest = diskfile
274
274
275 return if src == dest
275 return if src == dest
276
276
277 if !FileUtils.mkdir_p(File.dirname(dest))
277 if !FileUtils.mkdir_p(File.dirname(dest))
278 logger.error "Could not create directory #{File.dirname(dest)}" if logger
278 logger.error "Could not create directory #{File.dirname(dest)}" if logger
279 return
279 return
280 end
280 end
281
281
282 if !FileUtils.mv(src, dest)
282 if !FileUtils.mv(src, dest)
283 logger.error "Could not move attachment from #{src} to #{dest}" if logger
283 logger.error "Could not move attachment from #{src} to #{dest}" if logger
284 return
284 return
285 end
285 end
286
286
287 update_column :disk_directory, disk_directory
287 update_column :disk_directory, disk_directory
288 end
288 end
289
289
290 # Moves existing attachments that are stored at the root of the files
290 # Moves existing attachments that are stored at the root of the files
291 # directory (ie. created before Redmine 2.3) to their target subdirectories
291 # directory (ie. created before Redmine 2.3) to their target subdirectories
292 def self.move_from_root_to_target_directory
292 def self.move_from_root_to_target_directory
293 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
293 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
294 attachment.move_to_target_directory!
294 attachment.move_to_target_directory!
295 end
295 end
296 end
296 end
297
297
298 private
298 private
299
299
300 # Physically deletes the file from the file system
300 # Physically deletes the file from the file system
301 def delete_from_disk!
301 def delete_from_disk!
302 if disk_filename.present? && File.exist?(diskfile)
302 if disk_filename.present? && File.exist?(diskfile)
303 File.delete(diskfile)
303 File.delete(diskfile)
304 end
304 end
305 end
305 end
306
306
307 def sanitize_filename(value)
307 def sanitize_filename(value)
308 # get only the filename, not the whole path
308 # get only the filename, not the whole path
309 just_filename = value.gsub(/\A.*(\\|\/)/m, '')
309 just_filename = value.gsub(/\A.*(\\|\/)/m, '')
310
310
311 # Finally, replace invalid characters with underscore
311 # Finally, replace invalid characters with underscore
312 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
312 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
313 end
313 end
314
314
315 # Returns the subdirectory in which the attachment will be saved
315 # Returns the subdirectory in which the attachment will be saved
316 def target_directory
316 def target_directory
317 time = created_on || DateTime.now
317 time = created_on || DateTime.now
318 time.strftime("%Y/%m")
318 time.strftime("%Y/%m")
319 end
319 end
320
320
321 # Returns an ASCII or hashed filename that do not
321 # Returns an ASCII or hashed filename that do not
322 # exists yet in the given subdirectory
322 # exists yet in the given subdirectory
323 def self.disk_filename(filename, directory=nil)
323 def self.disk_filename(filename, directory=nil)
324 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
324 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
325 ascii = ''
325 ascii = ''
326 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
326 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
327 ascii = filename
327 ascii = filename
328 else
328 else
329 ascii = Digest::MD5.hexdigest(filename)
329 ascii = Digest::MD5.hexdigest(filename)
330 # keep the extension if any
330 # keep the extension if any
331 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
331 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
332 end
332 end
333 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
333 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
334 timestamp.succ!
334 timestamp.succ!
335 end
335 end
336 "#{timestamp}_#{ascii}"
336 "#{timestamp}_#{ascii}"
337 end
337 end
338 end
338 end
General Comments 0
You need to be logged in to leave comments. Login now