comparison app/models/attachment.rb @ 1338:25603efa57b5

Merge from live branch
author Chris Cannam
date Thu, 20 Jun 2013 13:14:14 +0100
parents 433d4f72a19b
children 622f24f53b42 261b3d9a4903
comparison
equal deleted inserted replaced
1209:1b1138f6f55e 1338:25603efa57b5
1 # Redmine - project management software 1 # Redmine - project management software
2 # Copyright (C) 2006-2011 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.
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 :container, :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 validates_length_of :description, :maximum => 255
27 validate :validate_max_file_size 28 validate :validate_max_file_size
28 29
29 acts_as_event :title => :filename, 30 acts_as_event :title => :filename,
30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}} 31 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
31 32
42 :find_options => {:select => "#{Attachment.table_name}.*", 43 :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 " + 44 :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"} 45 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
45 46
46 cattr_accessor :storage_path 47 cattr_accessor :storage_path
47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{Rails.root}/files" 48 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
49
50 cattr_accessor :thumbnails_storage_path
51 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
48 52
49 before_save :files_to_final_location 53 before_save :files_to_final_location
50 after_destroy :delete_from_disk 54 after_destroy :delete_from_disk
51 55
56 # Returns an unsaved copy of the attachment
57 def copy(attributes=nil)
58 copy = self.class.new
59 copy.attributes = self.attributes.dup.except("id", "downloads")
60 copy.attributes = attributes if attributes
61 copy
62 end
63
52 def validate_max_file_size 64 def validate_max_file_size
53 if self.filesize > Setting.attachment_max_size.to_i.kilobytes 65 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
54 errors.add(:base, :too_long, :count => Setting.attachment_max_size.to_i.kilobytes) 66 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
55 end 67 end
56 end 68 end
57 69
58 def file=(incoming_file) 70 def file=(incoming_file)
59 unless incoming_file.nil? 71 unless incoming_file.nil?
60 @temp_file = incoming_file 72 @temp_file = incoming_file
61 if @temp_file.size > 0 73 if @temp_file.size > 0
62 self.filename = sanitize_filename(@temp_file.original_filename) 74 if @temp_file.respond_to?(:original_filename)
63 self.disk_filename = Attachment.disk_filename(filename) 75 self.filename = @temp_file.original_filename
64 self.content_type = @temp_file.content_type.to_s.chomp 76 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
65 if content_type.blank? 77 end
78 if @temp_file.respond_to?(:content_type)
79 self.content_type = @temp_file.content_type.to_s.chomp
80 end
81 if content_type.blank? && filename.present?
66 self.content_type = Redmine::MimeType.of(filename) 82 self.content_type = Redmine::MimeType.of(filename)
67 end 83 end
68 self.filesize = @temp_file.size 84 self.filesize = @temp_file.size
69 end 85 end
70 end 86 end
71 end 87 end
72 88
73 def file 89 def file
74 nil 90 nil
91 end
92
93 def filename=(arg)
94 write_attribute :filename, sanitize_filename(arg.to_s)
95 if new_record? && disk_filename.blank?
96 self.disk_filename = Attachment.disk_filename(filename)
97 end
98 filename
75 end 99 end
76 100
77 # Copies the temporary file to its final location 101 # Copies the temporary file to its final location
78 # and computes its MD5 hash 102 # and computes its MD5 hash
79 def files_to_final_location 103 def files_to_final_location
80 if @temp_file && (@temp_file.size > 0) 104 if @temp_file && (@temp_file.size > 0)
81 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") 105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
82 md5 = Digest::MD5.new 106 md5 = Digest::MD5.new
83 File.open(diskfile, "wb") do |f| 107 File.open(diskfile, "wb") do |f|
84 buffer = "" 108 if @temp_file.respond_to?(:read)
85 while (buffer = @temp_file.read(8192)) 109 buffer = ""
86 f.write(buffer) 110 while (buffer = @temp_file.read(8192))
87 md5.update(buffer) 111 f.write(buffer)
112 md5.update(buffer)
113 end
114 else
115 f.write(@temp_file)
116 md5.update(@temp_file)
88 end 117 end
89 end 118 end
90 self.digest = md5.hexdigest 119 self.digest = md5.hexdigest
91 end 120 end
92 @temp_file = nil 121 @temp_file = nil
94 if self.content_type && self.content_type.length > 255 123 if self.content_type && self.content_type.length > 255
95 self.content_type = nil 124 self.content_type = nil
96 end 125 end
97 end 126 end
98 127
99 # Deletes file on the disk 128 # Deletes the file from the file system if it's not referenced by other attachments
100 def delete_from_disk 129 def delete_from_disk
101 File.delete(diskfile) if !filename.blank? && File.exist?(diskfile) 130 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
131 delete_from_disk!
132 end
102 end 133 end
103 134
104 # Returns file's location on disk 135 # Returns file's location on disk
105 def diskfile 136 def diskfile
106 "#{@@storage_path}/#{self.disk_filename}" 137 File.join(self.class.storage_path, disk_filename.to_s)
138 end
139
140 def title
141 title = filename.to_s
142 if description.present?
143 title << " (#{description})"
144 end
145 title
107 end 146 end
108 147
109 def increment_download 148 def increment_download
110 increment!(:downloads) 149 increment!(:downloads)
111 end 150 end
112 151
113 def project 152 def project
114 container.project 153 container.try(:project)
115 end 154 end
116 155
117 def visible?(user=User.current) 156 def visible?(user=User.current)
118 container.attachments_visible?(user) 157 container && container.attachments_visible?(user)
119 end 158 end
120 159
121 def deletable?(user=User.current) 160 def deletable?(user=User.current)
122 container.attachments_deletable?(user) 161 container && container.attachments_deletable?(user)
123 end 162 end
124 163
125 def image? 164 def image?
126 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i 165 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
166 end
167
168 def thumbnailable?
169 image?
170 end
171
172 # Returns the full path the attachment thumbnail, or nil
173 # if the thumbnail cannot be generated.
174 def thumbnail(options={})
175 if thumbnailable? && readable?
176 size = options[:size].to_i
177 if size > 0
178 # Limit the number of thumbnails per image
179 size = (size / 50) * 50
180 # Maximum thumbnail size
181 size = 800 if size > 800
182 else
183 size = Setting.thumbnails_size.to_i
184 end
185 size = 100 unless size > 0
186 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
187
188 begin
189 Redmine::Thumbnail.generate(self.diskfile, target, size)
190 rescue => e
191 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
192 return nil
193 end
194 end
195 end
196
197 # Deletes all thumbnails
198 def self.clear_thumbnails
199 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
200 File.delete file
201 end
127 end 202 end
128 203
129 def is_text? 204 def is_text?
130 Redmine::MimeType.is_type?('text', filename) 205 Redmine::MimeType.is_type?('text', filename)
131 end 206 end
135 end 210 end
136 211
137 # Returns true if the file is readable 212 # Returns true if the file is readable
138 def readable? 213 def readable?
139 File.readable?(diskfile) 214 File.readable?(diskfile)
215 end
216
217 # Returns the attachment token
218 def token
219 "#{id}.#{digest}"
220 end
221
222 # Finds an attachment that matches the given token and that has no container
223 def self.find_by_token(token)
224 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
225 attachment_id, attachment_digest = $1, $2
226 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
227 if attachment && attachment.container.nil?
228 attachment
229 end
230 end
140 end 231 end
141 232
142 # Bulk attaches a set of files to an object 233 # Bulk attaches a set of files to an object
143 # 234 #
144 # Returns a Hash of the results: 235 # Returns a Hash of the results:
145 # :files => array of the attached files 236 # :files => array of the attached files
146 # :unsaved => array of the files that could not be attached 237 # :unsaved => array of the files that could not be attached
147 def self.attach_files(obj, attachments) 238 def self.attach_files(obj, attachments)
148 attached = [] 239 result = obj.save_attachments(attachments, User.current)
149 if attachments && attachments.is_a?(Hash) 240 obj.attach_saved_attachments
150 attachments.each_value do |attachment| 241 result
151 file = attachment['file']
152 next unless file && file.size > 0
153 a = Attachment.create(:container => obj,
154 :file => file,
155 :description => attachment['description'].to_s.strip,
156 :author => User.current)
157 obj.attachments << a
158
159 if a.new_record?
160 obj.unsaved_attachments ||= []
161 obj.unsaved_attachments << a
162 else
163 attached << a
164 end
165 end
166 end
167 {:files => attached, :unsaved => obj.unsaved_attachments}
168 end 242 end
169 243
170 def self.latest_attach(attachments, filename) 244 def self.latest_attach(attachments, filename)
171 attachments.sort_by(&:created_on).reverse.detect { 245 attachments.sort_by(&:created_on).reverse.detect {
172 |att| att.filename.downcase == filename.downcase 246 |att| att.filename.downcase == filename.downcase
173 } 247 }
174 end 248 end
175 249
176 private 250 def self.prune(age=1.day)
251 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
252 end
253
254 private
255
256 # Physically deletes the file from the file system
257 def delete_from_disk!
258 if disk_filename.present? && File.exist?(diskfile)
259 File.delete(diskfile)
260 end
261 end
262
177 def sanitize_filename(value) 263 def sanitize_filename(value)
178 # get only the filename, not the whole path 264 # get only the filename, not the whole path
179 just_filename = value.gsub(/^.*(\\|\/)/, '') 265 just_filename = value.gsub(/^.*(\\|\/)/, '')
180 266
181 # Finally, replace invalid characters with underscore 267 # Finally, replace invalid characters with underscore