To check out this repository please hg clone the following URL, or open the URL using EasyMercurial or your preferred Mercurial client.
root / app / models / attachment.rb @ 1541:2696466256ff
History | View | Annotate | Download (10.7 KB)
| 1 | 441:cbce1fd3b1b7 | Chris | # Redmine - project management software
|
|---|---|---|---|
| 2 | 1494:e248c7af89ec | Chris | # Copyright (C) 2006-2014 Jean-Philippe Lang
|
| 3 | 0:513646585e45 | Chris | #
|
| 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 | 441:cbce1fd3b1b7 | Chris | #
|
| 9 | 0:513646585e45 | Chris | # 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 | 441:cbce1fd3b1b7 | Chris | #
|
| 14 | 0:513646585e45 | Chris | # 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 | require "digest/md5"
|
||
| 19 | 1464:261b3d9a4903 | Chris | require "fileutils"
|
| 20 | 0:513646585e45 | Chris | |
| 21 | class Attachment < ActiveRecord::Base |
||
| 22 | belongs_to :container, :polymorphic => true |
||
| 23 | belongs_to :author, :class_name => "User", :foreign_key => "author_id" |
||
| 24 | 441:cbce1fd3b1b7 | Chris | |
| 25 | 1115:433d4f72a19b | Chris | validates_presence_of :filename, :author |
| 26 | 0:513646585e45 | Chris | validates_length_of :filename, :maximum => 255 |
| 27 | validates_length_of :disk_filename, :maximum => 255 |
||
| 28 | 1115:433d4f72a19b | Chris | validates_length_of :description, :maximum => 255 |
| 29 | 909:cbb26bc654de | Chris | validate :validate_max_file_size
|
| 30 | 0:513646585e45 | Chris | |
| 31 | acts_as_event :title => :filename, |
||
| 32 | :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}} |
||
| 33 | |||
| 34 | acts_as_activity_provider :type => 'files', |
||
| 35 | :permission => :view_files, |
||
| 36 | :author_key => :author_id, |
||
| 37 | 441:cbce1fd3b1b7 | Chris | :find_options => {:select => "#{Attachment.table_name}.*", |
| 38 | 0:513646585e45 | Chris | :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " + |
| 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 )"}
|
||
| 40 | 441:cbce1fd3b1b7 | Chris | |
| 41 | 0:513646585e45 | Chris | acts_as_activity_provider :type => 'documents', |
| 42 | :permission => :view_documents, |
||
| 43 | :author_key => :author_id, |
||
| 44 | 441:cbce1fd3b1b7 | Chris | :find_options => {:select => "#{Attachment.table_name}.*", |
| 45 | 0:513646585e45 | Chris | :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " + |
| 46 | "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
|
||
| 47 | |||
| 48 | cattr_accessor :storage_path
|
||
| 49 | 1115:433d4f72a19b | Chris | @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files") |
| 50 | |||
| 51 | cattr_accessor :thumbnails_storage_path
|
||
| 52 | @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails") |
||
| 53 | 441:cbce1fd3b1b7 | Chris | |
| 54 | 909:cbb26bc654de | Chris | before_save :files_to_final_location
|
| 55 | after_destroy :delete_from_disk
|
||
| 56 | |||
| 57 | 1115:433d4f72a19b | Chris | # Returns an unsaved copy of the attachment
|
| 58 | def copy(attributes=nil) |
||
| 59 | copy = self.class.new
|
||
| 60 | copy.attributes = self.attributes.dup.except("id", "downloads") |
||
| 61 | copy.attributes = attributes if attributes
|
||
| 62 | copy |
||
| 63 | end
|
||
| 64 | |||
| 65 | 909:cbb26bc654de | Chris | def validate_max_file_size |
| 66 | 1115:433d4f72a19b | Chris | if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes |
| 67 | errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes)) |
||
| 68 | 0:513646585e45 | Chris | end
|
| 69 | end
|
||
| 70 | |||
| 71 | def file=(incoming_file) |
||
| 72 | unless incoming_file.nil?
|
||
| 73 | @temp_file = incoming_file
|
||
| 74 | if @temp_file.size > 0 |
||
| 75 | 1115:433d4f72a19b | Chris | if @temp_file.respond_to?(:original_filename) |
| 76 | self.filename = @temp_file.original_filename |
||
| 77 | self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding) |
||
| 78 | end
|
||
| 79 | if @temp_file.respond_to?(:content_type) |
||
| 80 | self.content_type = @temp_file.content_type.to_s.chomp |
||
| 81 | end
|
||
| 82 | if content_type.blank? && filename.present?
|
||
| 83 | 0:513646585e45 | Chris | self.content_type = Redmine::MimeType.of(filename) |
| 84 | end
|
||
| 85 | self.filesize = @temp_file.size |
||
| 86 | end
|
||
| 87 | end
|
||
| 88 | end
|
||
| 89 | 1115:433d4f72a19b | Chris | |
| 90 | 0:513646585e45 | Chris | def file |
| 91 | nil
|
||
| 92 | end
|
||
| 93 | |||
| 94 | 1115:433d4f72a19b | Chris | def filename=(arg) |
| 95 | write_attribute :filename, sanitize_filename(arg.to_s)
|
||
| 96 | filename |
||
| 97 | end
|
||
| 98 | |||
| 99 | 0:513646585e45 | Chris | # Copies the temporary file to its final location
|
| 100 | # and computes its MD5 hash
|
||
| 101 | 909:cbb26bc654de | Chris | def files_to_final_location |
| 102 | 0:513646585e45 | Chris | if @temp_file && (@temp_file.size > 0) |
| 103 | 1464:261b3d9a4903 | Chris | self.disk_directory = target_directory
|
| 104 | self.disk_filename = Attachment.disk_filename(filename, disk_directory) |
||
| 105 | logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger |
||
| 106 | path = File.dirname(diskfile)
|
||
| 107 | unless File.directory?(path) |
||
| 108 | FileUtils.mkdir_p(path)
|
||
| 109 | end
|
||
| 110 | 0:513646585e45 | Chris | md5 = Digest::MD5.new |
| 111 | 441:cbce1fd3b1b7 | Chris | File.open(diskfile, "wb") do |f| |
| 112 | 1115:433d4f72a19b | Chris | if @temp_file.respond_to?(:read) |
| 113 | buffer = ""
|
||
| 114 | while (buffer = @temp_file.read(8192)) |
||
| 115 | f.write(buffer) |
||
| 116 | md5.update(buffer) |
||
| 117 | end
|
||
| 118 | else
|
||
| 119 | f.write(@temp_file)
|
||
| 120 | md5.update(@temp_file)
|
||
| 121 | 0:513646585e45 | Chris | end
|
| 122 | end
|
||
| 123 | self.digest = md5.hexdigest
|
||
| 124 | end
|
||
| 125 | 909:cbb26bc654de | Chris | @temp_file = nil |
| 126 | 0:513646585e45 | Chris | # Don't save the content type if it's longer than the authorized length
|
| 127 | if self.content_type && self.content_type.length > 255 |
||
| 128 | self.content_type = nil |
||
| 129 | end
|
||
| 130 | end
|
||
| 131 | |||
| 132 | 1115:433d4f72a19b | Chris | # Deletes the file from the file system if it's not referenced by other attachments
|
| 133 | 909:cbb26bc654de | Chris | def delete_from_disk |
| 134 | 1115:433d4f72a19b | Chris | if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty? |
| 135 | delete_from_disk! |
||
| 136 | end
|
||
| 137 | 0:513646585e45 | Chris | end
|
| 138 | |||
| 139 | # Returns file's location on disk
|
||
| 140 | def diskfile |
||
| 141 | 1464:261b3d9a4903 | Chris | File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s) |
| 142 | 1115:433d4f72a19b | Chris | end
|
| 143 | |||
| 144 | def title |
||
| 145 | title = filename.to_s |
||
| 146 | if description.present?
|
||
| 147 | title << " (#{description})"
|
||
| 148 | end
|
||
| 149 | title |
||
| 150 | 0:513646585e45 | Chris | end
|
| 151 | 441:cbce1fd3b1b7 | Chris | |
| 152 | 0:513646585e45 | Chris | def increment_download |
| 153 | increment!(:downloads)
|
||
| 154 | end
|
||
| 155 | |||
| 156 | def project |
||
| 157 | 1115:433d4f72a19b | Chris | container.try(:project)
|
| 158 | 0:513646585e45 | Chris | end
|
| 159 | 441:cbce1fd3b1b7 | Chris | |
| 160 | 0:513646585e45 | Chris | def visible?(user=User.current) |
| 161 | 1464:261b3d9a4903 | Chris | if container_id
|
| 162 | container && container.attachments_visible?(user) |
||
| 163 | else
|
||
| 164 | author == user |
||
| 165 | end
|
||
| 166 | 0:513646585e45 | Chris | end
|
| 167 | 441:cbce1fd3b1b7 | Chris | |
| 168 | 0:513646585e45 | Chris | def deletable?(user=User.current) |
| 169 | 1464:261b3d9a4903 | Chris | if container_id
|
| 170 | container && container.attachments_deletable?(user) |
||
| 171 | else
|
||
| 172 | author == user |
||
| 173 | end
|
||
| 174 | 0:513646585e45 | Chris | end
|
| 175 | 441:cbce1fd3b1b7 | Chris | |
| 176 | 0:513646585e45 | Chris | def image? |
| 177 | 1115:433d4f72a19b | Chris | !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i) |
| 178 | end
|
||
| 179 | |||
| 180 | def thumbnailable? |
||
| 181 | image? |
||
| 182 | end
|
||
| 183 | |||
| 184 | # Returns the full path the attachment thumbnail, or nil
|
||
| 185 | # if the thumbnail cannot be generated.
|
||
| 186 | def thumbnail(options={}) |
||
| 187 | if thumbnailable? && readable?
|
||
| 188 | size = options[:size].to_i
|
||
| 189 | if size > 0 |
||
| 190 | # Limit the number of thumbnails per image
|
||
| 191 | size = (size / 50) * 50 |
||
| 192 | # Maximum thumbnail size
|
||
| 193 | size = 800 if size > 800 |
||
| 194 | else
|
||
| 195 | size = Setting.thumbnails_size.to_i
|
||
| 196 | end
|
||
| 197 | size = 100 unless size > 0 |
||
| 198 | target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb") |
||
| 199 | |||
| 200 | begin
|
||
| 201 | Redmine::Thumbnail.generate(self.diskfile, target, size) |
||
| 202 | rescue => e
|
||
| 203 | logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger |
||
| 204 | return nil |
||
| 205 | end
|
||
| 206 | end
|
||
| 207 | end
|
||
| 208 | |||
| 209 | # Deletes all thumbnails
|
||
| 210 | def self.clear_thumbnails |
||
| 211 | Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file| |
||
| 212 | File.delete file
|
||
| 213 | end
|
||
| 214 | 0:513646585e45 | Chris | end
|
| 215 | 441:cbce1fd3b1b7 | Chris | |
| 216 | 0:513646585e45 | Chris | def is_text? |
| 217 | Redmine::MimeType.is_type?('text', filename) |
||
| 218 | end
|
||
| 219 | 441:cbce1fd3b1b7 | Chris | |
| 220 | 0:513646585e45 | Chris | def is_diff? |
| 221 | self.filename =~ /\.(patch|diff)$/i |
||
| 222 | end
|
||
| 223 | 441:cbce1fd3b1b7 | Chris | |
| 224 | 0:513646585e45 | Chris | # Returns true if the file is readable
|
| 225 | def readable? |
||
| 226 | File.readable?(diskfile)
|
||
| 227 | end
|
||
| 228 | |||
| 229 | 1115:433d4f72a19b | Chris | # Returns the attachment token
|
| 230 | def token |
||
| 231 | "#{id}.#{digest}"
|
||
| 232 | end
|
||
| 233 | |||
| 234 | # Finds an attachment that matches the given token and that has no container
|
||
| 235 | def self.find_by_token(token) |
||
| 236 | if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/ |
||
| 237 | attachment_id, attachment_digest = $1, $2 |
||
| 238 | attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first |
||
| 239 | if attachment && attachment.container.nil?
|
||
| 240 | attachment |
||
| 241 | end
|
||
| 242 | end
|
||
| 243 | end
|
||
| 244 | |||
| 245 | 0:513646585e45 | Chris | # Bulk attaches a set of files to an object
|
| 246 | #
|
||
| 247 | # Returns a Hash of the results:
|
||
| 248 | # :files => array of the attached files
|
||
| 249 | # :unsaved => array of the files that could not be attached
|
||
| 250 | def self.attach_files(obj, attachments) |
||
| 251 | 1115:433d4f72a19b | Chris | result = obj.save_attachments(attachments, User.current)
|
| 252 | obj.attach_saved_attachments |
||
| 253 | result |
||
| 254 | 0:513646585e45 | Chris | end
|
| 255 | 441:cbce1fd3b1b7 | Chris | |
| 256 | 909:cbb26bc654de | Chris | def self.latest_attach(attachments, filename) |
| 257 | 1115:433d4f72a19b | Chris | attachments.sort_by(&:created_on).reverse.detect {
|
| 258 | 909:cbb26bc654de | Chris | |att| att.filename.downcase == filename.downcase |
| 259 | } |
||
| 260 | end
|
||
| 261 | |||
| 262 | 1115:433d4f72a19b | Chris | def self.prune(age=1.day) |
| 263 | Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all |
||
| 264 | end
|
||
| 265 | |||
| 266 | 1464:261b3d9a4903 | Chris | # Moves an existing attachment to its target directory
|
| 267 | def move_to_target_directory! |
||
| 268 | return unless !new_record? & readable? |
||
| 269 | |||
| 270 | src = diskfile |
||
| 271 | self.disk_directory = target_directory
|
||
| 272 | dest = diskfile |
||
| 273 | |||
| 274 | return if src == dest |
||
| 275 | |||
| 276 | if !FileUtils.mkdir_p(File.dirname(dest)) |
||
| 277 | logger.error "Could not create directory #{File.dirname(dest)}" if logger |
||
| 278 | return
|
||
| 279 | end
|
||
| 280 | |||
| 281 | if !FileUtils.mv(src, dest) |
||
| 282 | logger.error "Could not move attachment from #{src} to #{dest}" if logger |
||
| 283 | return
|
||
| 284 | end
|
||
| 285 | |||
| 286 | update_column :disk_directory, disk_directory
|
||
| 287 | end
|
||
| 288 | |||
| 289 | # Moves existing attachments that are stored at the root of the files
|
||
| 290 | # directory (ie. created before Redmine 2.3) to their target subdirectories
|
||
| 291 | def self.move_from_root_to_target_directory |
||
| 292 | Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment| |
||
| 293 | attachment.move_to_target_directory! |
||
| 294 | end
|
||
| 295 | end
|
||
| 296 | |||
| 297 | 1115:433d4f72a19b | Chris | private |
| 298 | |||
| 299 | # Physically deletes the file from the file system
|
||
| 300 | def delete_from_disk! |
||
| 301 | if disk_filename.present? && File.exist?(diskfile) |
||
| 302 | File.delete(diskfile)
|
||
| 303 | end
|
||
| 304 | end
|
||
| 305 | |||
| 306 | 0:513646585e45 | Chris | def sanitize_filename(value) |
| 307 | # get only the filename, not the whole path
|
||
| 308 | 1464:261b3d9a4903 | Chris | just_filename = value.gsub(/\A.*(\\|\/)/m, '') |
| 309 | 0:513646585e45 | Chris | |
| 310 | 909:cbb26bc654de | Chris | # Finally, replace invalid characters with underscore
|
| 311 | 1464:261b3d9a4903 | Chris | @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_') |
| 312 | 0:513646585e45 | Chris | end
|
| 313 | 441:cbce1fd3b1b7 | Chris | |
| 314 | 1464:261b3d9a4903 | Chris | # Returns the subdirectory in which the attachment will be saved
|
| 315 | def target_directory |
||
| 316 | time = created_on || DateTime.now
|
||
| 317 | time.strftime("%Y/%m")
|
||
| 318 | end
|
||
| 319 | |||
| 320 | # Returns an ASCII or hashed filename that do not
|
||
| 321 | # exists yet in the given subdirectory
|
||
| 322 | def self.disk_filename(filename, directory=nil) |
||
| 323 | 0:513646585e45 | Chris | timestamp = DateTime.now.strftime("%y%m%d%H%M%S") |
| 324 | ascii = ''
|
||
| 325 | if filename =~ %r{^[a-zA-Z0-9_\.\-]*$} |
||
| 326 | ascii = filename |
||
| 327 | else
|
||
| 328 | ascii = Digest::MD5.hexdigest(filename) |
||
| 329 | # keep the extension if any
|
||
| 330 | ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$} |
||
| 331 | end
|
||
| 332 | 1464:261b3d9a4903 | Chris | while File.exist?(File.join(storage_path, directory.to_s, "#{timestamp}_#{ascii}")) |
| 333 | 0:513646585e45 | Chris | timestamp.succ! |
| 334 | end
|
||
| 335 | "#{timestamp}_#{ascii}"
|
||
| 336 | end
|
||
| 337 | end |