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