File: app/models/attachment.rb

Overview
Module Structure
Class Hierarchy
Code

Overview

Module Structure

  module: <Toplevel Module>
  class: Attachment#20
inherits from
  Base ( ActiveRecord )
has properties
method: container_with_blank_type_check #52
method: copy / 1 #62
method: validate_max_file_size #69
method: file= / 1 #75
method: file #94
method: filename= / 1 #98
method: files_to_final_location #108
method: delete_from_disk #129
method: diskfile #136
method: increment_download #140
method: project #144
method: visible? / 1 #148
method: deletable? / 1 #152
method: image? #156
method: is_text? #160
method: is_diff? #164
method: readable? #169
method: token #174
class method: find_by_token / 1 #179
class method: attach_files / 2 #194
class method: latest_attach / 2 #200
class method: prune / 1 #206
method: delete_from_disk! #214
method: sanitize_filename / 1 #220
class method: disk_filename / 1 #229

Class Hierarchy

Object ( Builtin-Module )
Base ( ActiveRecord )
  Attachment    #20

Code

   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  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    validate :validate_max_file_size
  28 
  29    acts_as_event :title => :filename,
  30                  :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
  31 
  32    acts_as_activity_provider :type => 'files',
  33                              :permission => :view_files,
  34                              :author_key => :author_id,
  35                              :find_options => {:select => "#{Attachment.table_name}.*",
  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                                                          "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    acts_as_activity_provider :type => 'documents',
  40                              :permission => :view_documents,
  41                              :author_key => :author_id,
  42                              :find_options => {:select => "#{Attachment.table_name}.*",
  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                                                          "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
  45 
  46    cattr_accessor :storage_path
  47    @@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{Rails.root}/files"
  48 
  49    before_save :files_to_final_location
  50    after_destroy :delete_from_disk
  51 
  52    def container_with_blank_type_check
  53      if container_type.blank?
  54        nil
  55      else
  56        container_without_blank_type_check
  57      end
  58    end
  59    alias_method_chain :container, :blank_type_check unless method_defined?(:container_without_blank_type_check)
  60 
  61    # Returns an unsaved copy of the attachment
  62    def copy(attributes=nil)
  63      copy = self.class.new
  64      copy.attributes = self.attributes.dup.except("id", "downloads")
  65      copy.attributes = attributes if attributes
  66      copy
  67    end
  68 
  69    def validate_max_file_size
  70      if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
  71        errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
  72      end
  73    end
  74 
  75    def file=(incoming_file)
  76      unless incoming_file.nil?
  77        @temp_file = incoming_file
  78        if @temp_file.size > 0
  79          if @temp_file.respond_to?(:original_filename)
  80            self.filename = @temp_file.original_filename
  81            self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
  82          end
  83          if @temp_file.respond_to?(:content_type)
  84            self.content_type = @temp_file.content_type.to_s.chomp
  85          end
  86          if content_type.blank? && filename.present?
  87            self.content_type = Redmine::MimeType.of(filename)
  88          end
  89          self.filesize = @temp_file.size
  90        end
  91      end
  92    end
  93 
  94    def file
  95      nil
  96    end
  97 
  98    def filename=(arg)
  99      write_attribute :filename, sanitize_filename(arg.to_s)
 100      if new_record? && disk_filename.blank?
 101        self.disk_filename = Attachment.disk_filename(filename)
 102      end
 103      filename
 104    end
 105 
 106    # Copies the temporary file to its final location
 107    # and computes its MD5 hash
 108    def files_to_final_location
 109      if @temp_file && (@temp_file.size > 0)
 110        logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
 111        md5 = Digest::MD5.new
 112        File.open(diskfile, "wb") do |f|
 113          buffer = ""
 114          while (buffer = @temp_file.read(8192))
 115            f.write(buffer)
 116            md5.update(buffer)
 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.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil?
 131        delete_from_disk!
 132      end
 133    end
 134 
 135    # Returns file's location on disk
 136    def diskfile
 137      "#{@@storage_path}/#{self.disk_filename}"
 138    end
 139 
 140    def increment_download
 141      increment!(:downloads)
 142    end
 143 
 144    def project
 145      container.try(:project)
 146    end
 147 
 148    def visible?(user=User.current)
 149      container && container.attachments_visible?(user)
 150    end
 151 
 152    def deletable?(user=User.current)
 153      container && container.attachments_deletable?(user)
 154    end
 155 
 156    def image?
 157      self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
 158    end
 159 
 160    def is_text?
 161      Redmine::MimeType.is_type?('text', filename)
 162    end
 163 
 164    def is_diff?
 165      self.filename =~ /\.(patch|diff)$/i
 166    end
 167 
 168    # Returns true if the file is readable
 169    def readable?
 170      File.readable?(diskfile)
 171    end
 172 
 173    # Returns the attachment token
 174    def token
 175      "#{id}.#{digest}"
 176    end
 177 
 178    # Finds an attachment that matches the given token and that has no container
 179    def self.find_by_token(token)
 180      if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
 181        attachment_id, attachment_digest = $1, $2
 182        attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest})
 183        if attachment && attachment.container.nil?
 184          attachment
 185        end
 186      end
 187    end
 188 
 189    # Bulk attaches a set of files to an object
 190    #
 191    # Returns a Hash of the results:
 192    # :files => array of the attached files
 193    # :unsaved => array of the files that could not be attached
 194    def self.attach_files(obj, attachments)
 195      result = obj.save_attachments(attachments, User.current)
 196      obj.attach_saved_attachments
 197      result
 198    end
 199 
 200    def self.latest_attach(attachments, filename)
 201      attachments.sort_by(&:created_on).reverse.detect {
 202        |att| att.filename.downcase == filename.downcase
 203       }
 204    end
 205 
 206    def self.prune(age=1.day)
 207      attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age])
 208      attachments.each(&:destroy)
 209    end
 210 
 211    private
 212 
 213    # Physically deletes the file from the file system
 214    def delete_from_disk!
 215      if disk_filename.present? && File.exist?(diskfile)
 216        File.delete(diskfile)
 217      end
 218    end
 219 
 220    def sanitize_filename(value)
 221      # get only the filename, not the whole path
 222      just_filename = value.gsub(/^.*(\\|\/)/, '')
 223 
 224      # Finally, replace invalid characters with underscore
 225      @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
 226    end
 227 
 228    # Returns an ASCII or hashed filename
 229    def self.disk_filename(filename)
 230      timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
 231      ascii = ''
 232      if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
 233        ascii = filename
 234      else
 235        ascii = Digest::MD5.hexdigest(filename)
 236        # keep the extension if any
 237        ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
 238      end
 239      while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
 240        timestamp.succ!
 241      end
 242      "#{timestamp}_#{ascii}"
 243    end
 244  end