##// END OF EJS Templates
Adds Attachment#title....
Jean-Philippe Lang -
r9829:5c2de4dfc952
parent child
Show More
@@ -1,271 +1,279
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/md5"
19 19
20 20 class Attachment < ActiveRecord::Base
21 21 belongs_to :container, :polymorphic => true
22 22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23 23
24 24 validates_presence_of :filename, :author
25 25 validates_length_of :filename, :maximum => 255
26 26 validates_length_of :disk_filename, :maximum => 255
27 27 validates_length_of :description, :maximum => 255
28 28 validate :validate_max_file_size
29 29
30 30 acts_as_event :title => :filename,
31 31 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
32 32
33 33 acts_as_activity_provider :type => 'files',
34 34 :permission => :view_files,
35 35 :author_key => :author_id,
36 36 :find_options => {:select => "#{Attachment.table_name}.*",
37 37 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
38 38 "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 )"}
39 39
40 40 acts_as_activity_provider :type => 'documents',
41 41 :permission => :view_documents,
42 42 :author_key => :author_id,
43 43 :find_options => {:select => "#{Attachment.table_name}.*",
44 44 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
45 45 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
46 46
47 47 cattr_accessor :storage_path
48 48 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
49 49
50 50 cattr_accessor :thumbnails_storage_path
51 51 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
52 52
53 53 before_save :files_to_final_location
54 54 after_destroy :delete_from_disk
55 55
56 56 # Returns an unsaved copy of the attachment
57 57 def copy(attributes=nil)
58 58 copy = self.class.new
59 59 copy.attributes = self.attributes.dup.except("id", "downloads")
60 60 copy.attributes = attributes if attributes
61 61 copy
62 62 end
63 63
64 64 def validate_max_file_size
65 65 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
66 66 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
67 67 end
68 68 end
69 69
70 70 def file=(incoming_file)
71 71 unless incoming_file.nil?
72 72 @temp_file = incoming_file
73 73 if @temp_file.size > 0
74 74 if @temp_file.respond_to?(:original_filename)
75 75 self.filename = @temp_file.original_filename
76 76 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
77 77 end
78 78 if @temp_file.respond_to?(:content_type)
79 79 self.content_type = @temp_file.content_type.to_s.chomp
80 80 end
81 81 if content_type.blank? && filename.present?
82 82 self.content_type = Redmine::MimeType.of(filename)
83 83 end
84 84 self.filesize = @temp_file.size
85 85 end
86 86 end
87 87 end
88 88
89 89 def file
90 90 nil
91 91 end
92 92
93 93 def filename=(arg)
94 94 write_attribute :filename, sanitize_filename(arg.to_s)
95 95 if new_record? && disk_filename.blank?
96 96 self.disk_filename = Attachment.disk_filename(filename)
97 97 end
98 98 filename
99 99 end
100 100
101 101 # Copies the temporary file to its final location
102 102 # and computes its MD5 hash
103 103 def files_to_final_location
104 104 if @temp_file && (@temp_file.size > 0)
105 105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
106 106 md5 = Digest::MD5.new
107 107 File.open(diskfile, "wb") do |f|
108 108 if @temp_file.respond_to?(:read)
109 109 buffer = ""
110 110 while (buffer = @temp_file.read(8192))
111 111 f.write(buffer)
112 112 md5.update(buffer)
113 113 end
114 114 else
115 115 f.write(@temp_file)
116 116 md5.update(@temp_file)
117 117 end
118 118 end
119 119 self.digest = md5.hexdigest
120 120 end
121 121 @temp_file = nil
122 122 # Don't save the content type if it's longer than the authorized length
123 123 if self.content_type && self.content_type.length > 255
124 124 self.content_type = nil
125 125 end
126 126 end
127 127
128 128 # Deletes the file from the file system if it's not referenced by other attachments
129 129 def delete_from_disk
130 130 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
131 131 delete_from_disk!
132 132 end
133 133 end
134 134
135 135 # Returns file's location on disk
136 136 def diskfile
137 137 File.join(self.class.storage_path, disk_filename.to_s)
138 138 end
139 139
140 def title
141 title = filename.to_s
142 if description.present?
143 title << " (#{description})"
144 end
145 title
146 end
147
140 148 def increment_download
141 149 increment!(:downloads)
142 150 end
143 151
144 152 def project
145 153 container.try(:project)
146 154 end
147 155
148 156 def visible?(user=User.current)
149 157 container && container.attachments_visible?(user)
150 158 end
151 159
152 160 def deletable?(user=User.current)
153 161 container && container.attachments_deletable?(user)
154 162 end
155 163
156 164 def image?
157 165 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
158 166 end
159 167
160 168 def thumbnailable?
161 169 image?
162 170 end
163 171
164 172 # Returns the full path the attachment thumbnail, or nil
165 173 # if the thumbnail cannot be generated.
166 174 def thumbnail
167 175 if thumbnailable? && readable?
168 176 size = Setting.thumbnails_size.to_i
169 177 size = 100 unless size > 0
170 178 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
171 179
172 180 begin
173 181 Redmine::Thumbnail.generate(self.diskfile, target, size)
174 182 rescue => e
175 183 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
176 184 return nil
177 185 end
178 186 end
179 187 end
180 188
181 189 # Deletes all thumbnails
182 190 def self.clear_thumbnails
183 191 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
184 192 File.delete file
185 193 end
186 194 end
187 195
188 196 def is_text?
189 197 Redmine::MimeType.is_type?('text', filename)
190 198 end
191 199
192 200 def is_diff?
193 201 self.filename =~ /\.(patch|diff)$/i
194 202 end
195 203
196 204 # Returns true if the file is readable
197 205 def readable?
198 206 File.readable?(diskfile)
199 207 end
200 208
201 209 # Returns the attachment token
202 210 def token
203 211 "#{id}.#{digest}"
204 212 end
205 213
206 214 # Finds an attachment that matches the given token and that has no container
207 215 def self.find_by_token(token)
208 216 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
209 217 attachment_id, attachment_digest = $1, $2
210 218 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
211 219 if attachment && attachment.container.nil?
212 220 attachment
213 221 end
214 222 end
215 223 end
216 224
217 225 # Bulk attaches a set of files to an object
218 226 #
219 227 # Returns a Hash of the results:
220 228 # :files => array of the attached files
221 229 # :unsaved => array of the files that could not be attached
222 230 def self.attach_files(obj, attachments)
223 231 result = obj.save_attachments(attachments, User.current)
224 232 obj.attach_saved_attachments
225 233 result
226 234 end
227 235
228 236 def self.latest_attach(attachments, filename)
229 237 attachments.sort_by(&:created_on).reverse.detect {
230 238 |att| att.filename.downcase == filename.downcase
231 239 }
232 240 end
233 241
234 242 def self.prune(age=1.day)
235 243 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
236 244 end
237 245
238 246 private
239 247
240 248 # Physically deletes the file from the file system
241 249 def delete_from_disk!
242 250 if disk_filename.present? && File.exist?(diskfile)
243 251 File.delete(diskfile)
244 252 end
245 253 end
246 254
247 255 def sanitize_filename(value)
248 256 # get only the filename, not the whole path
249 257 just_filename = value.gsub(/^.*(\\|\/)/, '')
250 258
251 259 # Finally, replace invalid characters with underscore
252 260 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
253 261 end
254 262
255 263 # Returns an ASCII or hashed filename
256 264 def self.disk_filename(filename)
257 265 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
258 266 ascii = ''
259 267 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
260 268 ascii = filename
261 269 else
262 270 ascii = Digest::MD5.hexdigest(filename)
263 271 # keep the extension if any
264 272 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
265 273 end
266 274 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
267 275 timestamp.succ!
268 276 end
269 277 "#{timestamp}_#{ascii}"
270 278 end
271 279 end
@@ -1,247 +1,255
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class AttachmentTest < ActiveSupport::TestCase
23 23 fixtures :users, :projects, :roles, :members, :member_roles,
24 24 :enabled_modules, :issues, :trackers, :attachments
25 25
26 26 class MockFile
27 27 attr_reader :original_filename, :content_type, :content, :size
28 28
29 29 def initialize(attributes)
30 30 @original_filename = attributes[:original_filename]
31 31 @content_type = attributes[:content_type]
32 32 @content = attributes[:content] || "Content"
33 33 @size = content.size
34 34 end
35 35 end
36 36
37 37 def setup
38 38 set_tmp_attachments_directory
39 39 end
40 40
41 41 def test_container_for_new_attachment_should_be_nil
42 42 assert_nil Attachment.new.container
43 43 end
44 44
45 45 def test_create
46 46 a = Attachment.new(:container => Issue.find(1),
47 47 :file => uploaded_test_file("testfile.txt", "text/plain"),
48 48 :author => User.find(1))
49 49 assert a.save
50 50 assert_equal 'testfile.txt', a.filename
51 51 assert_equal 59, a.filesize
52 52 assert_equal 'text/plain', a.content_type
53 53 assert_equal 0, a.downloads
54 54 assert_equal '1478adae0d4eb06d35897518540e25d6', a.digest
55 55 assert File.exist?(a.diskfile)
56 56 assert_equal 59, File.size(a.diskfile)
57 57 end
58 58
59 59 def test_size_should_be_validated_for_new_file
60 60 with_settings :attachment_max_size => 0 do
61 61 a = Attachment.new(:container => Issue.find(1),
62 62 :file => uploaded_test_file("testfile.txt", "text/plain"),
63 63 :author => User.find(1))
64 64 assert !a.save
65 65 end
66 66 end
67 67
68 68 def test_size_should_not_be_validated_when_copying
69 69 a = Attachment.create!(:container => Issue.find(1),
70 70 :file => uploaded_test_file("testfile.txt", "text/plain"),
71 71 :author => User.find(1))
72 72 with_settings :attachment_max_size => 0 do
73 73 copy = a.copy
74 74 assert copy.save
75 75 end
76 76 end
77 77
78 78 def test_description_length_should_be_validated
79 79 a = Attachment.new(:description => 'a' * 300)
80 80 assert !a.save
81 81 assert_not_nil a.errors[:description]
82 82 end
83 83
84 84 def test_destroy
85 85 a = Attachment.new(:container => Issue.find(1),
86 86 :file => uploaded_test_file("testfile.txt", "text/plain"),
87 87 :author => User.find(1))
88 88 assert a.save
89 89 assert_equal 'testfile.txt', a.filename
90 90 assert_equal 59, a.filesize
91 91 assert_equal 'text/plain', a.content_type
92 92 assert_equal 0, a.downloads
93 93 assert_equal '1478adae0d4eb06d35897518540e25d6', a.digest
94 94 diskfile = a.diskfile
95 95 assert File.exist?(diskfile)
96 96 assert_equal 59, File.size(a.diskfile)
97 97 assert a.destroy
98 98 assert !File.exist?(diskfile)
99 99 end
100 100
101 101 def test_destroy_should_not_delete_file_referenced_by_other_attachment
102 102 a = Attachment.create!(:container => Issue.find(1),
103 103 :file => uploaded_test_file("testfile.txt", "text/plain"),
104 104 :author => User.find(1))
105 105 diskfile = a.diskfile
106 106
107 107 copy = a.copy
108 108 copy.save!
109 109
110 110 assert File.exists?(diskfile)
111 111 a.destroy
112 112 assert File.exists?(diskfile)
113 113 copy.destroy
114 114 assert !File.exists?(diskfile)
115 115 end
116 116
117 117 def test_create_should_auto_assign_content_type
118 118 a = Attachment.new(:container => Issue.find(1),
119 119 :file => uploaded_test_file("testfile.txt", ""),
120 120 :author => User.find(1))
121 121 assert a.save
122 122 assert_equal 'text/plain', a.content_type
123 123 end
124 124
125 125 def test_identical_attachments_at_the_same_time_should_not_overwrite
126 126 a1 = Attachment.create!(:container => Issue.find(1),
127 127 :file => uploaded_test_file("testfile.txt", ""),
128 128 :author => User.find(1))
129 129 a2 = Attachment.create!(:container => Issue.find(1),
130 130 :file => uploaded_test_file("testfile.txt", ""),
131 131 :author => User.find(1))
132 132 assert a1.disk_filename != a2.disk_filename
133 133 end
134 134
135 135 def test_filename_should_be_basenamed
136 136 a = Attachment.new(:file => MockFile.new(:original_filename => "path/to/the/file"))
137 137 assert_equal 'file', a.filename
138 138 end
139 139
140 140 def test_filename_should_be_sanitized
141 141 a = Attachment.new(:file => MockFile.new(:original_filename => "valid:[] invalid:?%*|\"'<>chars"))
142 142 assert_equal 'valid_[] invalid_chars', a.filename
143 143 end
144 144
145 145 def test_diskfilename
146 146 assert Attachment.disk_filename("test_file.txt") =~ /^\d{12}_test_file.txt$/
147 147 assert_equal 'test_file.txt', Attachment.disk_filename("test_file.txt")[13..-1]
148 148 assert_equal '770c509475505f37c2b8fb6030434d6b.txt', Attachment.disk_filename("test_accentuΓ©.txt")[13..-1]
149 149 assert_equal 'f8139524ebb8f32e51976982cd20a85d', Attachment.disk_filename("test_accentuΓ©")[13..-1]
150 150 assert_equal 'cbb5b0f30978ba03731d61f9f6d10011', Attachment.disk_filename("test_accentuΓ©.Γ§a")[13..-1]
151 151 end
152 152
153 def test_title
154 a = Attachment.new(:filename => "test.png")
155 assert_equal "test.png", a.title
156
157 a = Attachment.new(:filename => "test.png", :description => "Cool image")
158 assert_equal "test.png (Cool image)", a.title
159 end
160
153 161 def test_prune_should_destroy_old_unattached_attachments
154 162 Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1, :created_on => 2.days.ago)
155 163 Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1, :created_on => 2.days.ago)
156 164 Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
157 165
158 166 assert_difference 'Attachment.count', -2 do
159 167 Attachment.prune
160 168 end
161 169 end
162 170
163 171 context "Attachmnet.attach_files" do
164 172 should "attach the file" do
165 173 issue = Issue.first
166 174 assert_difference 'Attachment.count' do
167 175 Attachment.attach_files(issue,
168 176 '1' => {
169 177 'file' => uploaded_test_file('testfile.txt', 'text/plain'),
170 178 'description' => 'test'
171 179 })
172 180 end
173 181
174 182 attachment = Attachment.first(:order => 'id DESC')
175 183 assert_equal issue, attachment.container
176 184 assert_equal 'testfile.txt', attachment.filename
177 185 assert_equal 59, attachment.filesize
178 186 assert_equal 'test', attachment.description
179 187 assert_equal 'text/plain', attachment.content_type
180 188 assert File.exists?(attachment.diskfile)
181 189 assert_equal 59, File.size(attachment.diskfile)
182 190 end
183 191
184 192 should "add unsaved files to the object as unsaved attachments" do
185 193 # Max size of 0 to force Attachment creation failures
186 194 with_settings(:attachment_max_size => 0) do
187 195 @project = Project.find(1)
188 196 response = Attachment.attach_files(@project, {
189 197 '1' => {'file' => mock_file, 'description' => 'test'},
190 198 '2' => {'file' => mock_file, 'description' => 'test'}
191 199 })
192 200
193 201 assert response[:unsaved].present?
194 202 assert_equal 2, response[:unsaved].length
195 203 assert response[:unsaved].first.new_record?
196 204 assert response[:unsaved].second.new_record?
197 205 assert_equal response[:unsaved], @project.unsaved_attachments
198 206 end
199 207 end
200 208 end
201 209
202 210 def test_latest_attach
203 211 set_fixtures_attachments_directory
204 212 a1 = Attachment.find(16)
205 213 assert_equal "testfile.png", a1.filename
206 214 assert a1.readable?
207 215 assert (! a1.visible?(User.anonymous))
208 216 assert a1.visible?(User.find(2))
209 217 a2 = Attachment.find(17)
210 218 assert_equal "testfile.PNG", a2.filename
211 219 assert a2.readable?
212 220 assert (! a2.visible?(User.anonymous))
213 221 assert a2.visible?(User.find(2))
214 222 assert a1.created_on < a2.created_on
215 223
216 224 la1 = Attachment.latest_attach([a1, a2], "testfile.png")
217 225 assert_equal 17, la1.id
218 226 la2 = Attachment.latest_attach([a1, a2], "Testfile.PNG")
219 227 assert_equal 17, la2.id
220 228
221 229 set_tmp_attachments_directory
222 230 end
223 231
224 232 def test_thumbnailable_should_be_true_for_images
225 233 assert_equal true, Attachment.new(:filename => 'test.jpg').thumbnailable?
226 234 end
227 235
228 236 def test_thumbnailable_should_be_true_for_non_images
229 237 assert_equal false, Attachment.new(:filename => 'test.txt').thumbnailable?
230 238 end
231 239
232 240 if convert_installed?
233 241 def test_thumbnail_should_generate_the_thumbnail
234 242 set_fixtures_attachments_directory
235 243 attachment = Attachment.find(16)
236 244 Attachment.clear_thumbnails
237 245
238 246 assert_difference "Dir.glob(File.join(Attachment.thumbnails_storage_path, '*.thumb')).size" do
239 247 thumbnail = attachment.thumbnail
240 248 assert_equal "16_8e0294de2441577c529f170b6fb8f638_100.thumb", File.basename(thumbnail)
241 249 assert File.exists?(thumbnail)
242 250 end
243 251 end
244 252 else
245 253 puts '(ImageMagick convert not available)'
246 254 end
247 255 end
General Comments 0
You need to be logged in to leave comments. Login now