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