##// END OF EJS Templates
Store attachments in subdirectories (#5298)....
Jean-Philippe Lang -
r10761:c99b638d61cc
parent child
Show More
@@ -0,0 +1,9
1 class AddAttachmentsDiskDirectory < ActiveRecord::Migration
2 def up
3 add_column :attachments, :disk_directory, :string
4 end
5
6 def down
7 remove_column :attachments, :disk_directory
8 end
9 end
@@ -1,295 +1,326
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 require "fileutils"
19 20
20 21 class Attachment < ActiveRecord::Base
21 22 belongs_to :container, :polymorphic => true
22 23 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23 24
24 25 validates_presence_of :filename, :author
25 26 validates_length_of :filename, :maximum => 255
26 27 validates_length_of :disk_filename, :maximum => 255
27 28 validates_length_of :description, :maximum => 255
28 29 validate :validate_max_file_size
29 30
30 31 acts_as_event :title => :filename,
31 32 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
32 33
33 34 acts_as_activity_provider :type => 'files',
34 35 :permission => :view_files,
35 36 :author_key => :author_id,
36 37 :find_options => {:select => "#{Attachment.table_name}.*",
37 38 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
38 39 "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 40
40 41 acts_as_activity_provider :type => 'documents',
41 42 :permission => :view_documents,
42 43 :author_key => :author_id,
43 44 :find_options => {:select => "#{Attachment.table_name}.*",
44 45 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
45 46 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
46 47
47 48 cattr_accessor :storage_path
48 49 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
49 50
50 51 cattr_accessor :thumbnails_storage_path
51 52 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
52 53
53 54 before_save :files_to_final_location
54 55 after_destroy :delete_from_disk
55 56
56 57 # Returns an unsaved copy of the attachment
57 58 def copy(attributes=nil)
58 59 copy = self.class.new
59 60 copy.attributes = self.attributes.dup.except("id", "downloads")
60 61 copy.attributes = attributes if attributes
61 62 copy
62 63 end
63 64
64 65 def validate_max_file_size
65 66 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
66 67 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
67 68 end
68 69 end
69 70
70 71 def file=(incoming_file)
71 72 unless incoming_file.nil?
72 73 @temp_file = incoming_file
73 74 if @temp_file.size > 0
74 75 if @temp_file.respond_to?(:original_filename)
75 76 self.filename = @temp_file.original_filename
76 77 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
77 78 end
78 79 if @temp_file.respond_to?(:content_type)
79 80 self.content_type = @temp_file.content_type.to_s.chomp
80 81 end
81 82 if content_type.blank? && filename.present?
82 83 self.content_type = Redmine::MimeType.of(filename)
83 84 end
84 85 self.filesize = @temp_file.size
85 86 end
86 87 end
87 88 end
88 89
89 90 def file
90 91 nil
91 92 end
92 93
93 94 def filename=(arg)
94 95 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 96 filename
99 97 end
100 98
101 99 # Copies the temporary file to its final location
102 100 # and computes its MD5 hash
103 101 def files_to_final_location
104 102 if @temp_file && (@temp_file.size > 0)
103 self.disk_directory = target_directory
104 self.disk_filename = Attachment.disk_filename(filename, disk_directory)
105 105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
106 path = File.dirname(diskfile)
107 unless File.directory?(path)
108 FileUtils.mkdir_p(path)
109 end
106 110 md5 = Digest::MD5.new
107 111 File.open(diskfile, "wb") do |f|
108 112 if @temp_file.respond_to?(:read)
109 113 buffer = ""
110 114 while (buffer = @temp_file.read(8192))
111 115 f.write(buffer)
112 116 md5.update(buffer)
113 117 end
114 118 else
115 119 f.write(@temp_file)
116 120 md5.update(@temp_file)
117 121 end
118 122 end
119 123 self.digest = md5.hexdigest
120 124 end
121 125 @temp_file = nil
122 126 # Don't save the content type if it's longer than the authorized length
123 127 if self.content_type && self.content_type.length > 255
124 128 self.content_type = nil
125 129 end
126 130 end
127 131
128 132 # Deletes the file from the file system if it's not referenced by other attachments
129 133 def delete_from_disk
130 134 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
131 135 delete_from_disk!
132 136 end
133 137 end
134 138
135 139 # Returns file's location on disk
136 140 def diskfile
137 File.join(self.class.storage_path, disk_filename.to_s)
141 File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
138 142 end
139 143
140 144 def title
141 145 title = filename.to_s
142 146 if description.present?
143 147 title << " (#{description})"
144 148 end
145 149 title
146 150 end
147 151
148 152 def increment_download
149 153 increment!(:downloads)
150 154 end
151 155
152 156 def project
153 157 container.try(:project)
154 158 end
155 159
156 160 def visible?(user=User.current)
157 161 if container_id
158 162 container && container.attachments_visible?(user)
159 163 else
160 164 author == user
161 165 end
162 166 end
163 167
164 168 def deletable?(user=User.current)
165 169 if container_id
166 170 container && container.attachments_deletable?(user)
167 171 else
168 172 author == user
169 173 end
170 174 end
171 175
172 176 def image?
173 177 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
174 178 end
175 179
176 180 def thumbnailable?
177 181 image?
178 182 end
179 183
180 184 # Returns the full path the attachment thumbnail, or nil
181 185 # if the thumbnail cannot be generated.
182 186 def thumbnail(options={})
183 187 if thumbnailable? && readable?
184 188 size = options[:size].to_i
185 189 if size > 0
186 190 # Limit the number of thumbnails per image
187 191 size = (size / 50) * 50
188 192 # Maximum thumbnail size
189 193 size = 800 if size > 800
190 194 else
191 195 size = Setting.thumbnails_size.to_i
192 196 end
193 197 size = 100 unless size > 0
194 198 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
195 199
196 200 begin
197 201 Redmine::Thumbnail.generate(self.diskfile, target, size)
198 202 rescue => e
199 203 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
200 204 return nil
201 205 end
202 206 end
203 207 end
204 208
205 209 # Deletes all thumbnails
206 210 def self.clear_thumbnails
207 211 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
208 212 File.delete file
209 213 end
210 214 end
211 215
212 216 def is_text?
213 217 Redmine::MimeType.is_type?('text', filename)
214 218 end
215 219
216 220 def is_diff?
217 221 self.filename =~ /\.(patch|diff)$/i
218 222 end
219 223
220 224 # Returns true if the file is readable
221 225 def readable?
222 226 File.readable?(diskfile)
223 227 end
224 228
225 229 # Returns the attachment token
226 230 def token
227 231 "#{id}.#{digest}"
228 232 end
229 233
230 234 # Finds an attachment that matches the given token and that has no container
231 235 def self.find_by_token(token)
232 236 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
233 237 attachment_id, attachment_digest = $1, $2
234 238 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
235 239 if attachment && attachment.container.nil?
236 240 attachment
237 241 end
238 242 end
239 243 end
240 244
241 245 # Bulk attaches a set of files to an object
242 246 #
243 247 # Returns a Hash of the results:
244 248 # :files => array of the attached files
245 249 # :unsaved => array of the files that could not be attached
246 250 def self.attach_files(obj, attachments)
247 251 result = obj.save_attachments(attachments, User.current)
248 252 obj.attach_saved_attachments
249 253 result
250 254 end
251 255
252 256 def self.latest_attach(attachments, filename)
253 257 attachments.sort_by(&:created_on).reverse.detect {
254 258 |att| att.filename.downcase == filename.downcase
255 259 }
256 260 end
257 261
258 262 def self.prune(age=1.day)
259 263 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
260 264 end
261 265
266 # Moves an existing attachment to its target directory
267 def move_to_target_directory!
268 if !new_record? & readable?
269 src = diskfile
270 self.disk_directory = target_directory
271 dest = diskfile
272 if src != dest && FileUtils.mkdir_p(File.dirname(dest)) && FileUtils.mv(src, dest)
273 update_column :disk_directory, disk_directory
274 end
275 end
276 end
277
278 # Moves existing attachments that are stored at the root of the files
279 # directory (ie. created before Redmine 2.3) to their target subdirectories
280 def self.move_from_root_to_target_directory
281 Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
282 attachment.move_to_target_directory!
283 end
284 end
285
262 286 private
263 287
264 288 # Physically deletes the file from the file system
265 289 def delete_from_disk!
266 290 if disk_filename.present? && File.exist?(diskfile)
267 291 File.delete(diskfile)
268 292 end
269 293 end
270 294
271 295 def sanitize_filename(value)
272 296 # get only the filename, not the whole path
273 297 just_filename = value.gsub(/^.*(\\|\/)/, '')
274 298
275 299 # Finally, replace invalid characters with underscore
276 300 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
277 301 end
278 302
279 # Returns an ASCII or hashed filename
280 def self.disk_filename(filename)
303 # Returns the subdirectory in which the attachment will be saved
304 def target_directory
305 time = created_on || DateTime.now
306 time.strftime("%Y/%m")
307 end
308
309 # Returns an ASCII or hashed filename that do not
310 # exists yet in the given subdirectory
311 def self.disk_filename(filename, directory=nil)
281 312 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
282 313 ascii = ''
283 314 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
284 315 ascii = filename
285 316 else
286 317 ascii = Digest::MD5.hexdigest(filename)
287 318 # keep the extension if any
288 319 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
289 320 end
290 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
321 while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}"))
291 322 timestamp.succ!
292 323 end
293 324 "#{timestamp}_#{ascii}"
294 325 end
295 326 end
@@ -1,121 +1,126
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 namespace :redmine do
19 19 namespace :attachments do
20 20 desc 'Removes uploaded files left unattached after one day.'
21 21 task :prune => :environment do
22 22 Attachment.prune
23 23 end
24
25 desc 'Moves attachments stored at the root of the file directory (ie. created before Redmine 2.3) to their subdirectories'
26 task :move_to_subdirectories => :environment do
27 Attachment.move_from_root_to_target_directory
28 end
24 29 end
25 30
26 31 namespace :tokens do
27 32 desc 'Removes expired tokens.'
28 33 task :prune => :environment do
29 34 Token.destroy_expired
30 35 end
31 36 end
32 37
33 38 namespace :watchers do
34 39 desc 'Removes watchers from what they can no longer view.'
35 40 task :prune => :environment do
36 41 Watcher.prune
37 42 end
38 43 end
39 44
40 45 desc 'Fetch changesets from the repositories'
41 46 task :fetch_changesets => :environment do
42 47 Repository.fetch_changesets
43 48 end
44 49
45 50 desc 'Migrates and copies plugins assets.'
46 51 task :plugins do
47 52 Rake::Task["redmine:plugins:migrate"].invoke
48 53 Rake::Task["redmine:plugins:assets"].invoke
49 54 end
50 55
51 56 namespace :plugins do
52 57 desc 'Migrates installed plugins.'
53 58 task :migrate => :environment do
54 59 name = ENV['NAME']
55 60 version = nil
56 61 version_string = ENV['VERSION']
57 62 if version_string
58 63 if version_string =~ /^\d+$/
59 64 version = version_string.to_i
60 65 if name.nil?
61 66 abort "The VERSION argument requires a plugin NAME."
62 67 end
63 68 else
64 69 abort "Invalid VERSION #{version_string} given."
65 70 end
66 71 end
67 72
68 73 begin
69 74 Redmine::Plugin.migrate(name, version)
70 75 rescue Redmine::PluginNotFound
71 76 abort "Plugin #{name} was not found."
72 77 end
73 78
74 79 Rake::Task["db:schema:dump"].invoke
75 80 end
76 81
77 82 desc 'Copies plugins assets into the public directory.'
78 83 task :assets => :environment do
79 84 name = ENV['NAME']
80 85
81 86 begin
82 87 Redmine::Plugin.mirror_assets(name)
83 88 rescue Redmine::PluginNotFound
84 89 abort "Plugin #{name} was not found."
85 90 end
86 91 end
87 92
88 93 desc 'Runs the plugins tests.'
89 94 task :test do
90 95 Rake::Task["redmine:plugins:test:units"].invoke
91 96 Rake::Task["redmine:plugins:test:functionals"].invoke
92 97 Rake::Task["redmine:plugins:test:integration"].invoke
93 98 end
94 99
95 100 namespace :test do
96 101 desc 'Runs the plugins unit tests.'
97 102 Rake::TestTask.new :units => "db:test:prepare" do |t|
98 103 t.libs << "test"
99 104 t.verbose = true
100 105 t.pattern = "plugins/#{ENV['NAME'] || '*'}/test/unit/**/*_test.rb"
101 106 end
102 107
103 108 desc 'Runs the plugins functional tests.'
104 109 Rake::TestTask.new :functionals => "db:test:prepare" do |t|
105 110 t.libs << "test"
106 111 t.verbose = true
107 112 t.pattern = "plugins/#{ENV['NAME'] || '*'}/test/functional/**/*_test.rb"
108 113 end
109 114
110 115 desc 'Runs the plugins integration tests.'
111 116 Rake::TestTask.new :integration => "db:test:prepare" do |t|
112 117 t.libs << "test"
113 118 t.verbose = true
114 119 t.pattern = "plugins/#{ENV['NAME'] || '*'}/test/integration/**/*_test.rb"
115 120 end
116 121 end
117 122 end
118 123 end
119 124
120 125 # Load plugins' rake tasks
121 126 Dir[File.join(Rails.root, "plugins/*/lib/tasks/**/*.rake")].sort.each { |ext| load ext }
@@ -1,236 +1,269
1 1 ---
2 2 attachments_001:
3 3 created_on: 2006-07-19 21:07:27 +02:00
4 4 downloads: 0
5 5 content_type: text/plain
6 6 disk_filename: 060719210727_error281.txt
7 disk_directory: "2006/07"
7 8 container_id: 3
8 9 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
9 10 id: 1
10 11 container_type: Issue
11 12 filesize: 28
12 13 filename: error281.txt
13 14 author_id: 2
14 15 attachments_002:
15 16 created_on: 2007-01-27 15:08:27 +01:00
16 17 downloads: 0
17 18 content_type: text/plain
18 19 disk_filename: 060719210727_document.txt
20 disk_directory: "2006/07"
19 21 container_id: 1
20 22 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
21 23 id: 2
22 24 container_type: Document
23 25 filesize: 28
24 26 filename: document.txt
25 27 author_id: 2
26 28 attachments_003:
27 29 created_on: 2006-07-19 21:07:27 +02:00
28 30 downloads: 0
29 31 content_type: image/gif
30 32 disk_filename: 060719210727_logo.gif
33 disk_directory: "2006/07"
31 34 container_id: 4
32 35 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
33 36 id: 3
34 37 container_type: WikiPage
35 38 filesize: 280
36 39 filename: logo.gif
37 40 description: This is a logo
38 41 author_id: 2
39 42 attachments_004:
40 43 created_on: 2006-07-19 21:07:27 +02:00
41 44 container_type: Issue
42 45 container_id: 3
43 46 downloads: 0
44 47 disk_filename: 060719210727_source.rb
48 disk_directory: "2006/07"
45 49 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
46 50 id: 4
47 51 filesize: 153
48 52 filename: source.rb
49 53 author_id: 2
50 54 description: This is a Ruby source file
51 55 content_type: application/x-ruby
52 56 attachments_005:
53 57 created_on: 2006-07-19 21:07:27 +02:00
54 58 container_type: Issue
55 59 container_id: 3
56 60 downloads: 0
57 61 disk_filename: 060719210727_changeset_iso8859-1.diff
62 disk_directory: "2006/07"
58 63 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
59 64 id: 5
60 65 filesize: 687
61 66 filename: changeset_iso8859-1.diff
62 67 author_id: 2
63 68 content_type: text/x-diff
64 69 attachments_006:
65 70 created_on: 2006-07-19 21:07:27 +02:00
66 71 container_type: Issue
67 72 container_id: 3
68 73 downloads: 0
69 74 disk_filename: 060719210727_archive.zip
75 disk_directory: "2006/07"
70 76 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
71 77 id: 6
72 78 filesize: 157
73 79 filename: archive.zip
74 80 author_id: 2
75 81 content_type: application/octet-stream
76 82 attachments_007:
77 83 created_on: 2006-07-19 21:07:27 +02:00
78 84 container_type: Issue
79 85 container_id: 4
80 86 downloads: 0
81 87 disk_filename: 060719210727_archive.zip
88 disk_directory: "2006/07"
82 89 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
83 90 id: 7
84 91 filesize: 157
85 92 filename: archive.zip
86 93 author_id: 1
87 94 content_type: application/octet-stream
88 95 attachments_008:
89 96 created_on: 2006-07-19 21:07:27 +02:00
90 97 container_type: Project
91 98 container_id: 1
92 99 downloads: 0
93 100 disk_filename: 060719210727_project_file.zip
101 disk_directory: "2006/07"
94 102 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
95 103 id: 8
96 104 filesize: 320
97 105 filename: project_file.zip
98 106 author_id: 2
99 107 content_type: application/octet-stream
100 108 attachments_009:
101 109 created_on: 2006-07-19 21:07:27 +02:00
102 110 container_type: Version
103 111 container_id: 1
104 112 downloads: 0
105 113 disk_filename: 060719210727_archive.zip
114 disk_directory: "2006/07"
106 115 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
107 116 id: 9
108 117 filesize: 452
109 118 filename: version_file.zip
110 119 author_id: 2
111 120 content_type: application/octet-stream
112 121 attachments_010:
113 122 created_on: 2006-07-19 21:07:27 +02:00
114 123 container_type: Issue
115 124 container_id: 2
116 125 downloads: 0
117 126 disk_filename: 060719210727_picture.jpg
127 disk_directory: "2006/07"
118 128 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
119 129 id: 10
120 130 filesize: 452
121 131 filename: picture.jpg
122 132 author_id: 2
123 133 content_type: image/jpeg
124 134 attachments_011:
125 135 created_on: 2007-02-12 15:08:27 +01:00
126 136 container_type: Document
127 137 container_id: 1
128 138 downloads: 0
129 139 disk_filename: 060719210727_picture.jpg
140 disk_directory: "2006/07"
130 141 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
131 142 id: 11
132 143 filesize: 452
133 144 filename: picture.jpg
134 145 author_id: 2
135 146 content_type: image/jpeg
136 147 attachments_012:
137 148 created_on: 2006-07-19 21:07:27 +02:00
138 149 container_type: Version
139 150 container_id: 1
140 151 downloads: 0
141 152 disk_filename: 060719210727_version_file.zip
153 disk_directory: "2006/07"
142 154 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
143 155 id: 12
144 156 filesize: 452
145 157 filename: version_file.zip
146 158 author_id: 2
147 159 content_type: application/octet-stream
148 160 attachments_013:
149 161 created_on: 2006-07-19 21:07:27 +02:00
150 162 container_type: Message
151 163 container_id: 1
152 164 downloads: 0
153 165 disk_filename: 060719210727_foo.zip
166 disk_directory: "2006/07"
154 167 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
155 168 id: 13
156 169 filesize: 452
157 170 filename: foo.zip
158 171 author_id: 2
159 172 content_type: application/octet-stream
160 173 attachments_014:
161 174 created_on: 2006-07-19 21:07:27 +02:00
162 175 container_type: Issue
163 176 container_id: 3
164 177 downloads: 0
165 178 disk_filename: 060719210727_changeset_utf8.diff
179 disk_directory: "2006/07"
166 180 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
167 181 id: 14
168 182 filesize: 687
169 183 filename: changeset_utf8.diff
170 184 author_id: 2
171 185 content_type: text/x-diff
172 186 attachments_015:
173 187 id: 15
174 188 created_on: 2010-07-19 21:07:27 +02:00
175 189 container_type: Issue
176 190 container_id: 14
177 191 downloads: 0
178 192 disk_filename: 060719210727_changeset_utf8.diff
193 disk_directory: "2006/07"
179 194 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
180 195 filesize: 687
181 196 filename: private.diff
182 197 author_id: 2
183 198 content_type: text/x-diff
184 199 description: attachement of a private issue
185 200 attachments_016:
186 201 content_type: image/png
187 202 downloads: 0
188 203 created_on: 2010-11-23 16:14:50 +09:00
189 204 disk_filename: 101123161450_testfile_1.png
205 disk_directory: "2010/11"
190 206 container_id: 14
191 207 digest: 8e0294de2441577c529f170b6fb8f638
192 208 id: 16
193 209 container_type: Issue
194 210 description: ""
195 211 filename: testfile.png
196 212 filesize: 2654
197 213 author_id: 2
198 214 attachments_017:
199 215 content_type: image/png
200 216 downloads: 0
201 217 created_on: 2010-12-23 16:14:50 +09:00
202 218 disk_filename: 101223161450_testfile_2.png
219 disk_directory: "2010/12"
203 220 container_id: 14
204 221 digest: 6bc2963e8d7ea0d3e68d12d1fba3d6ca
205 222 id: 17
206 223 container_type: Issue
207 224 description: ""
208 225 filename: testfile.PNG
209 226 filesize: 3582
210 227 author_id: 2
211 228 attachments_018:
212 229 content_type: image/png
213 230 downloads: 0
214 231 created_on: 2011-01-23 16:14:50 +09:00
215 232 disk_filename: 101123161450_testfile_1.png
233 disk_directory: "2010/11"
216 234 container_id: 14
217 235 digest: 8e0294de2441577c529f170b6fb8f638
218 236 id: 18
219 237 container_type: Issue
220 238 description: ""
221 239 filename: testγƒ†γ‚Ήγƒˆ.png
222 240 filesize: 2654
223 241 author_id: 2
224 242 attachments_019:
225 243 content_type: image/png
226 244 downloads: 0
227 245 created_on: 2011-02-23 16:14:50 +09:00
228 246 disk_filename: 101223161450_testfile_2.png
247 disk_directory: "2010/12"
229 248 container_id: 14
230 249 digest: 6bc2963e8d7ea0d3e68d12d1fba3d6ca
231 250 id: 19
232 251 container_type: Issue
233 252 description: ""
234 253 filename: Testγƒ†γ‚Ήγƒˆ.PNG
235 254 filesize: 3582
236 255 author_id: 2
256 attachments_020:
257 content_type: text/plain
258 downloads: 0
259 created_on: 2012-05-12 16:14:50 +09:00
260 disk_filename: 120512161450_root_attachment.txt
261 disk_directory:
262 container_id: 14
263 digest: b0fe2abdb2599743d554a61d7da7ff74
264 id: 20
265 container_type: Issue
266 description: ""
267 filename: root_attachment.txt
268 filesize: 54
269 author_id: 2
1 NO CONTENT: file renamed from test/fixtures/files/060719210727_archive.zip to test/fixtures/files/2006/07/060719210727_archive.zip
1 NO CONTENT: file renamed from test/fixtures/files/060719210727_changeset_iso8859-1.diff to test/fixtures/files/2006/07/060719210727_changeset_iso8859-1.diff
1 NO CONTENT: file renamed from test/fixtures/files/060719210727_changeset_utf8.diff to test/fixtures/files/2006/07/060719210727_changeset_utf8.diff
1 NO CONTENT: file renamed from test/fixtures/files/060719210727_source.rb to test/fixtures/files/2006/07/060719210727_source.rb
1 NO CONTENT: file renamed from test/fixtures/files/101123161450_testfile_1.png to test/fixtures/files/2010/11/101123161450_testfile_1.png
1 NO CONTENT: file renamed from test/fixtures/files/101223161450_testfile_2.png to test/fixtures/files/2010/12/101223161450_testfile_2.png
@@ -1,255 +1,288
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
56 assert a.disk_directory
57 assert_match %r{\A\d{4}/\d{2}\z}, a.disk_directory
58
55 59 assert File.exist?(a.diskfile)
56 60 assert_equal 59, File.size(a.diskfile)
57 61 end
58 62
63 def test_copy_should_preserve_attributes
64 a = Attachment.find(1)
65 copy = a.copy
66
67 assert_save copy
68 copy = Attachment.order('id DESC').first
69 %w(filename filesize content_type author_id created_on description digest disk_filename disk_directory diskfile).each do |attribute|
70 assert_equal a.send(attribute), copy.send(attribute), "#{attribute} was different"
71 end
72 end
73
59 74 def test_size_should_be_validated_for_new_file
60 75 with_settings :attachment_max_size => 0 do
61 76 a = Attachment.new(:container => Issue.find(1),
62 77 :file => uploaded_test_file("testfile.txt", "text/plain"),
63 78 :author => User.find(1))
64 79 assert !a.save
65 80 end
66 81 end
67 82
68 83 def test_size_should_not_be_validated_when_copying
69 84 a = Attachment.create!(:container => Issue.find(1),
70 85 :file => uploaded_test_file("testfile.txt", "text/plain"),
71 86 :author => User.find(1))
72 87 with_settings :attachment_max_size => 0 do
73 88 copy = a.copy
74 89 assert copy.save
75 90 end
76 91 end
77 92
78 93 def test_description_length_should_be_validated
79 94 a = Attachment.new(:description => 'a' * 300)
80 95 assert !a.save
81 96 assert_not_nil a.errors[:description]
82 97 end
83 98
84 99 def test_destroy
85 100 a = Attachment.new(:container => Issue.find(1),
86 101 :file => uploaded_test_file("testfile.txt", "text/plain"),
87 102 :author => User.find(1))
88 103 assert a.save
89 104 assert_equal 'testfile.txt', a.filename
90 105 assert_equal 59, a.filesize
91 106 assert_equal 'text/plain', a.content_type
92 107 assert_equal 0, a.downloads
93 108 assert_equal '1478adae0d4eb06d35897518540e25d6', a.digest
94 109 diskfile = a.diskfile
95 110 assert File.exist?(diskfile)
96 111 assert_equal 59, File.size(a.diskfile)
97 112 assert a.destroy
98 113 assert !File.exist?(diskfile)
99 114 end
100 115
101 116 def test_destroy_should_not_delete_file_referenced_by_other_attachment
102 117 a = Attachment.create!(:container => Issue.find(1),
103 118 :file => uploaded_test_file("testfile.txt", "text/plain"),
104 119 :author => User.find(1))
105 120 diskfile = a.diskfile
106 121
107 122 copy = a.copy
108 123 copy.save!
109 124
110 125 assert File.exists?(diskfile)
111 126 a.destroy
112 127 assert File.exists?(diskfile)
113 128 copy.destroy
114 129 assert !File.exists?(diskfile)
115 130 end
116 131
117 132 def test_create_should_auto_assign_content_type
118 133 a = Attachment.new(:container => Issue.find(1),
119 134 :file => uploaded_test_file("testfile.txt", ""),
120 135 :author => User.find(1))
121 136 assert a.save
122 137 assert_equal 'text/plain', a.content_type
123 138 end
124 139
125 140 def test_identical_attachments_at_the_same_time_should_not_overwrite
141 time = DateTime.now
142 DateTime.stubs(:now).returns(time)
143
126 144 a1 = Attachment.create!(:container => Issue.find(1),
127 145 :file => uploaded_test_file("testfile.txt", ""),
128 146 :author => User.find(1))
129 147 a2 = Attachment.create!(:container => Issue.find(1),
130 148 :file => uploaded_test_file("testfile.txt", ""),
131 149 :author => User.find(1))
132 150 assert a1.disk_filename != a2.disk_filename
133 151 end
134 152
135 153 def test_filename_should_be_basenamed
136 154 a = Attachment.new(:file => MockFile.new(:original_filename => "path/to/the/file"))
137 155 assert_equal 'file', a.filename
138 156 end
139 157
140 158 def test_filename_should_be_sanitized
141 159 a = Attachment.new(:file => MockFile.new(:original_filename => "valid:[] invalid:?%*|\"'<>chars"))
142 160 assert_equal 'valid_[] invalid_chars', a.filename
143 161 end
144 162
145 163 def test_diskfilename
146 164 assert Attachment.disk_filename("test_file.txt") =~ /^\d{12}_test_file.txt$/
147 165 assert_equal 'test_file.txt', Attachment.disk_filename("test_file.txt")[13..-1]
148 166 assert_equal '770c509475505f37c2b8fb6030434d6b.txt', Attachment.disk_filename("test_accentuΓ©.txt")[13..-1]
149 167 assert_equal 'f8139524ebb8f32e51976982cd20a85d', Attachment.disk_filename("test_accentuΓ©")[13..-1]
150 168 assert_equal 'cbb5b0f30978ba03731d61f9f6d10011', Attachment.disk_filename("test_accentuΓ©.Γ§a")[13..-1]
151 169 end
152 170
153 171 def test_title
154 172 a = Attachment.new(:filename => "test.png")
155 173 assert_equal "test.png", a.title
156 174
157 175 a = Attachment.new(:filename => "test.png", :description => "Cool image")
158 176 assert_equal "test.png (Cool image)", a.title
159 177 end
160 178
161 179 def test_prune_should_destroy_old_unattached_attachments
162 180 Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1, :created_on => 2.days.ago)
163 181 Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1, :created_on => 2.days.ago)
164 182 Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
165 183
166 184 assert_difference 'Attachment.count', -2 do
167 185 Attachment.prune
168 186 end
169 187 end
170 188
189 def test_move_from_root_to_target_directory_should_move_root_files
190 a = Attachment.find(20)
191 assert a.disk_directory.blank?
192 # Create a real file for this fixture
193 File.open(a.diskfile, "w") do |f|
194 f.write "test file at the root of files directory"
195 end
196 assert a.readable?
197 Attachment.move_from_root_to_target_directory
198
199 a.reload
200 assert_equal '2012/05', a.disk_directory
201 assert a.readable?
202 end
203
171 204 context "Attachmnet.attach_files" do
172 205 should "attach the file" do
173 206 issue = Issue.first
174 207 assert_difference 'Attachment.count' do
175 208 Attachment.attach_files(issue,
176 209 '1' => {
177 210 'file' => uploaded_test_file('testfile.txt', 'text/plain'),
178 211 'description' => 'test'
179 212 })
180 213 end
181 214
182 215 attachment = Attachment.first(:order => 'id DESC')
183 216 assert_equal issue, attachment.container
184 217 assert_equal 'testfile.txt', attachment.filename
185 218 assert_equal 59, attachment.filesize
186 219 assert_equal 'test', attachment.description
187 220 assert_equal 'text/plain', attachment.content_type
188 221 assert File.exists?(attachment.diskfile)
189 222 assert_equal 59, File.size(attachment.diskfile)
190 223 end
191 224
192 225 should "add unsaved files to the object as unsaved attachments" do
193 226 # Max size of 0 to force Attachment creation failures
194 227 with_settings(:attachment_max_size => 0) do
195 228 @project = Project.find(1)
196 229 response = Attachment.attach_files(@project, {
197 230 '1' => {'file' => mock_file, 'description' => 'test'},
198 231 '2' => {'file' => mock_file, 'description' => 'test'}
199 232 })
200 233
201 234 assert response[:unsaved].present?
202 235 assert_equal 2, response[:unsaved].length
203 236 assert response[:unsaved].first.new_record?
204 237 assert response[:unsaved].second.new_record?
205 238 assert_equal response[:unsaved], @project.unsaved_attachments
206 239 end
207 240 end
208 241 end
209 242
210 243 def test_latest_attach
211 244 set_fixtures_attachments_directory
212 245 a1 = Attachment.find(16)
213 246 assert_equal "testfile.png", a1.filename
214 247 assert a1.readable?
215 248 assert (! a1.visible?(User.anonymous))
216 249 assert a1.visible?(User.find(2))
217 250 a2 = Attachment.find(17)
218 251 assert_equal "testfile.PNG", a2.filename
219 252 assert a2.readable?
220 253 assert (! a2.visible?(User.anonymous))
221 254 assert a2.visible?(User.find(2))
222 255 assert a1.created_on < a2.created_on
223 256
224 257 la1 = Attachment.latest_attach([a1, a2], "testfile.png")
225 258 assert_equal 17, la1.id
226 259 la2 = Attachment.latest_attach([a1, a2], "Testfile.PNG")
227 260 assert_equal 17, la2.id
228 261
229 262 set_tmp_attachments_directory
230 263 end
231 264
232 265 def test_thumbnailable_should_be_true_for_images
233 266 assert_equal true, Attachment.new(:filename => 'test.jpg').thumbnailable?
234 267 end
235 268
236 269 def test_thumbnailable_should_be_true_for_non_images
237 270 assert_equal false, Attachment.new(:filename => 'test.txt').thumbnailable?
238 271 end
239 272
240 273 if convert_installed?
241 274 def test_thumbnail_should_generate_the_thumbnail
242 275 set_fixtures_attachments_directory
243 276 attachment = Attachment.find(16)
244 277 Attachment.clear_thumbnails
245 278
246 279 assert_difference "Dir.glob(File.join(Attachment.thumbnails_storage_path, '*.thumb')).size" do
247 280 thumbnail = attachment.thumbnail
248 281 assert_equal "16_8e0294de2441577c529f170b6fb8f638_100.thumb", File.basename(thumbnail)
249 282 assert File.exists?(thumbnail)
250 283 end
251 284 end
252 285 else
253 286 puts '(ImageMagick convert not available)'
254 287 end
255 288 end
General Comments 0
You need to be logged in to leave comments. Login now