attachment.rb
235 lines
| 7.9 KiB
| text/x-ruby
|
RubyLexer
|
r5673 | # Redmine - project management software | ||
|
r8892 | # Copyright (C) 2006-2012 Jean-Philippe Lang | ||
|
r330 | # | ||
# This program is free software; you can redistribute it and/or | ||||
# modify it under the terms of the GNU General Public License | ||||
# as published by the Free Software Foundation; either version 2 | ||||
# of the License, or (at your option) any later version. | ||||
|
r5673 | # | ||
|
r330 | # This program is distributed in the hope that it will be useful, | ||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
|
r5673 | # | ||
|
r330 | # You should have received a copy of the GNU General Public License | ||
# along with this program; if not, write to the Free Software | ||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
require "digest/md5" | ||||
class Attachment < ActiveRecord::Base | ||||
belongs_to :container, :polymorphic => true | ||||
belongs_to :author, :class_name => "User", :foreign_key => "author_id" | ||||
|
r5673 | |||
|
r8771 | validates_presence_of :filename, :author | ||
|
r590 | validates_length_of :filename, :maximum => 255 | ||
validates_length_of :disk_filename, :maximum => 255 | ||||
|
r6594 | validate :validate_max_file_size | ||
|
r663 | |||
acts_as_event :title => :filename, | ||||
|
r1669 | :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}} | ||
|
r663 | |||
|
r1692 | acts_as_activity_provider :type => 'files', | ||
:permission => :view_files, | ||||
|
r2064 | :author_key => :author_id, | ||
|
r5673 | :find_options => {:select => "#{Attachment.table_name}.*", | ||
|
r1692 | :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " + | ||
|
r2501 | "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 )"} | ||
|
r5673 | |||
|
r1692 | acts_as_activity_provider :type => 'documents', | ||
:permission => :view_documents, | ||||
|
r2064 | :author_key => :author_id, | ||
|
r5673 | :find_options => {:select => "#{Attachment.table_name}.*", | ||
|
r1692 | :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " + | ||
"LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"} | ||||
|
r330 | cattr_accessor :storage_path | ||
|
r9381 | @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files") | ||
|
r5673 | |||
|
r6636 | before_save :files_to_final_location | ||
|
r6643 | after_destroy :delete_from_disk | ||
|
r6636 | |||
|
r8556 | # Returns an unsaved copy of the attachment | ||
def copy(attributes=nil) | ||||
copy = self.class.new | ||||
copy.attributes = self.attributes.dup.except("id", "downloads") | ||||
copy.attributes = attributes if attributes | ||||
copy | ||||
end | ||||
|
r6594 | def validate_max_file_size | ||
|
r8556 | if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes | ||
|
r8817 | errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes)) | ||
|
r2575 | end | ||
|
r330 | end | ||
|
r1306 | def file=(incoming_file) | ||
unless incoming_file.nil? | ||||
@temp_file = incoming_file | ||||
if @temp_file.size > 0 | ||||
|
r8808 | if @temp_file.respond_to?(:original_filename) | ||
self.filename = @temp_file.original_filename | ||||
|
r9200 | self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding) | ||
|
r8808 | end | ||
if @temp_file.respond_to?(:content_type) | ||||
self.content_type = @temp_file.content_type.to_s.chomp | ||||
end | ||||
if content_type.blank? && filename.present? | ||||
|
r3144 | self.content_type = Redmine::MimeType.of(filename) | ||
end | ||||
|
r1306 | self.filesize = @temp_file.size | ||
end | ||||
end | ||||
end | ||||
|
r8892 | |||
|
r1306 | def file | ||
nil | ||||
end | ||||
|
r8808 | def filename=(arg) | ||
write_attribute :filename, sanitize_filename(arg.to_s) | ||||
if new_record? && disk_filename.blank? | ||||
self.disk_filename = Attachment.disk_filename(filename) | ||||
end | ||||
filename | ||||
end | ||||
|
r2578 | # Copies the temporary file to its final location | ||
# and computes its MD5 hash | ||||
|
r6636 | def files_to_final_location | ||
|
r1306 | if @temp_file && (@temp_file.size > 0) | ||
|
r6200 | logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") | ||
|
r2578 | md5 = Digest::MD5.new | ||
|
r5673 | File.open(diskfile, "wb") do |f| | ||
|
r2578 | buffer = "" | ||
while (buffer = @temp_file.read(8192)) | ||||
f.write(buffer) | ||||
md5.update(buffer) | ||||
end | ||||
|
r1306 | end | ||
|
r2578 | self.digest = md5.hexdigest | ||
|
r1306 | end | ||
|
r6200 | @temp_file = nil | ||
|
r1306 | # Don't save the content type if it's longer than the authorized length | ||
if self.content_type && self.content_type.length > 255 | ||||
self.content_type = nil | ||||
end | ||||
end | ||||
|
r8556 | # Deletes the file from the file system if it's not referenced by other attachments | ||
|
r6643 | def delete_from_disk | ||
|
r8556 | if Attachment.first(:conditions => ["disk_filename = ? AND id <> ?", disk_filename, id]).nil? | ||
delete_from_disk! | ||||
end | ||||
|
r1306 | end | ||
# Returns file's location on disk | ||||
def diskfile | ||||
|
r9381 | File.join(self.class.storage_path, disk_filename.to_s) | ||
|
r1306 | end | ||
|
r5673 | |||
|
r330 | def increment_download | ||
increment!(:downloads) | ||||
end | ||||
|
r538 | |||
def project | ||||
|
r8771 | container.try(:project) | ||
|
r538 | end | ||
|
r5673 | |||
|
r2114 | def visible?(user=User.current) | ||
|
r8771 | container && container.attachments_visible?(user) | ||
|
r2114 | end | ||
|
r5673 | |||
|
r2114 | def deletable?(user=User.current) | ||
|
r8771 | container && container.attachments_deletable?(user) | ||
|
r2114 | end | ||
|
r5673 | |||
|
r636 | def image? | ||
|
r7771 | self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i | ||
|
r636 | end | ||
|
r5673 | |||
|
r1506 | def is_text? | ||
Redmine::MimeType.is_type?('text', filename) | ||||
end | ||||
|
r5673 | |||
|
r1502 | def is_diff? | ||
self.filename =~ /\.(patch|diff)$/i | ||||
end | ||||
|
r5673 | |||
|
r2600 | # Returns true if the file is readable | ||
def readable? | ||||
File.readable?(diskfile) | ||||
end | ||||
|
r3409 | |||
|
r8771 | # Returns the attachment token | ||
def token | ||||
"#{id}.#{digest}" | ||||
end | ||||
# Finds an attachment that matches the given token and that has no container | ||||
def self.find_by_token(token) | ||||
if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/ | ||||
attachment_id, attachment_digest = $1, $2 | ||||
attachment = Attachment.first(:conditions => {:id => attachment_id, :digest => attachment_digest}) | ||||
if attachment && attachment.container.nil? | ||||
attachment | ||||
end | ||||
end | ||||
end | ||||
|
r3409 | # Bulk attaches a set of files to an object | ||
# | ||||
# Returns a Hash of the results: | ||||
# :files => array of the attached files | ||||
# :unsaved => array of the files that could not be attached | ||||
def self.attach_files(obj, attachments) | ||||
|
r8771 | result = obj.save_attachments(attachments, User.current) | ||
obj.attach_saved_attachments | ||||
result | ||||
|
r3409 | end | ||
|
r5673 | |||
|
r7788 | def self.latest_attach(attachments, filename) | ||
|
r8892 | attachments.sort_by(&:created_on).reverse.detect { | ||
|
r7788 | |att| att.filename.downcase == filename.downcase | ||
} | ||||
end | ||||
|
r8771 | def self.prune(age=1.day) | ||
|
r8773 | attachments = Attachment.all(:conditions => ["created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age]) | ||
|
r8771 | attachments.each(&:destroy) | ||
end | ||||
|
r8556 | private | ||
# Physically deletes the file from the file system | ||||
def delete_from_disk! | ||||
if disk_filename.present? && File.exist?(diskfile) | ||||
File.delete(diskfile) | ||||
end | ||||
end | ||||
|
r330 | def sanitize_filename(value) | ||
|
r1306 | # get only the filename, not the whole path | ||
just_filename = value.gsub(/^.*(\\|\/)/, '') | ||||
|
r330 | |||
|
r7797 | # Finally, replace invalid characters with underscore | ||
@filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_') | ||||
|
r330 | end | ||
|
r5673 | |||
|
r1418 | # Returns an ASCII or hashed filename | ||
def self.disk_filename(filename) | ||||
|
r3397 | timestamp = DateTime.now.strftime("%y%m%d%H%M%S") | ||
ascii = '' | ||||
|
r1418 | if filename =~ %r{^[a-zA-Z0-9_\.\-]*$} | ||
|
r3397 | ascii = filename | ||
|
r1418 | else | ||
|
r3397 | ascii = Digest::MD5.hexdigest(filename) | ||
|
r1418 | # keep the extension if any | ||
|
r3397 | ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$} | ||
|
r1418 | end | ||
|
r3397 | while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}")) | ||
timestamp.succ! | ||||
end | ||||
"#{timestamp}_#{ascii}" | ||||
|
r1418 | end | ||
|
r2 | end | ||