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