canvas-lms/app/models/attachment.rb

1541 lines
56 KiB
Ruby

#
# Copyright (C) 2011 - 2014 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas 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 Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'atom'
# See the uploads controller and views for examples on how to use this model.
class Attachment < ActiveRecord::Base
def self.display_name_order_by_clause(table = nil)
col = table ? "#{table}.display_name" : 'display_name'
best_unicode_collation_key(col)
end
attr_accessible :context, :folder, :filename, :display_name, :user, :locked, :position, :lock_at, :unlock_at, :uploaded_data, :hidden
EXPORTABLE_ATTRIBUTES = [
:id, :context_id, :context_type, :size, :folder_id, :content_type, :filename, :uuid, :display_name, :created_at, :updated_at,
:workflow_state, :user_id, :local_filename, :locked, :file_state, :deleted_at,
:position, :lock_at, :unlock_at, :last_lock_at, :last_unlock_at, :could_be_locked, :root_attachment_id, :cloned_item_id,
:namespace, :media_entry_id, :encoding, :need_notify, :upload_error_message
]
EXPORTABLE_ASSOCIATIONS = [:context, :folder, :user, :media_object, :submission]
include PolymorphicTypeOverride
override_polymorphic_types context_type: {'QuizStatistics' => 'Quizzes::QuizStatistics',
'QuizSubmission' => 'Quizzes::QuizSubmission'}
EXCLUDED_COPY_ATTRIBUTES = %w{id root_attachment_id uuid folder_id user_id
filename namespace workflow_state}
include HasContentTags
include ContextModuleItem
include SearchTermHelper
attr_accessor :podcast_associated_asset
# this is a gross hack to work around freaking SubmissionComment#attachments=
attr_accessor :ok_for_submission_comment
belongs_to :context, :polymorphic => true
belongs_to :cloned_item
belongs_to :folder
belongs_to :user
has_one :account_report
has_one :media_object
has_many :submissions
has_many :attachment_associations
belongs_to :root_attachment, :class_name => 'Attachment'
belongs_to :replacement_attachment, :class_name => 'Attachment'
has_one :sis_batch
has_one :thumbnail, :foreign_key => "parent_id", :conditions => {:thumbnail => "thumb"}
has_many :thumbnails, :foreign_key => "parent_id"
has_many :children, foreign_key: :root_attachment_id, class_name: 'Attachment'
has_one :crocodoc_document
has_one :canvadoc
belongs_to :usage_rights
before_save :infer_display_name
before_save :default_values
before_save :set_need_notify
before_validation :assert_attachment
acts_as_list :scope => :folder
def self.file_store_config
# Return existing value, even if nil, as long as it's defined
@file_store_config ||= ConfigFile.load('file_store')
@file_store_config ||= { 'storage' => 'local' }
@file_store_config['path_prefix'] ||= @file_store_config['path'] || 'tmp/files'
@file_store_config['path_prefix'] = nil if @file_store_config['path_prefix'] == 'tmp/files' && @file_store_config['storage'] == 's3'
return @file_store_config
end
def self.s3_config
# Return existing value, even if nil, as long as it's defined
return @s3_config if defined?(@s3_config)
@s3_config ||= ConfigFile.load('amazon_s3')
end
def self.s3_storage?
(file_store_config['storage'] rescue nil) == 's3' && s3_config
end
def self.local_storage?
rv = !s3_storage?
raise "Unknown storage type!" if rv && file_store_config['storage'] != 'local'
rv
end
def self.store_type
if s3_storage?
Attachments::S3Storage
elsif local_storage?
Attachments::LocalStorage
else
raise "Unknown storage system configured"
end
end
def store
@store ||= Attachment.store_type.new(self)
end
# Haaay... you're changing stuff here? Don't forget about the Thumbnail model
# too, it cares about local vs s3 storage.
has_attachment(
:storage => self.store_type.key,
:path_prefix => file_store_config['path_prefix'],
:s3_access => :private,
:thumbnails => { :thumb => '128x128' },
:thumbnail_class => 'Thumbnail'
)
# These callbacks happen after the attachment data is saved to disk/s3, or
# immediately after save if no data is being uploading during this save cycle.
# That means you can't rely on these happening in the same transaction as the save.
after_save_and_attachment_processing :touch_context_if_appropriate
after_save_and_attachment_processing :ensure_media_object
# this mixin can be added to a has_many :attachments association, and it'll
# handle finding replaced attachments. In other words, if an attachment fond
# by id is deleted but an active attachment in the same context has the same
# path, it'll return that attachment.
module FindInContextAssociation
def find(*a)
find_with_possibly_replaced(super)
end
def find_by_id(id)
find_with_possibly_replaced(where(id: id).first)
end
def find_all_by_id(ids)
find_with_possibly_replaced(where(id: ids).to_a)
end
def find_with_possibly_replaced(a_or_as)
if a_or_as.is_a?(Attachment)
find_attachment_possibly_replaced(a_or_as)
elsif a_or_as.is_a?(Array)
a_or_as.map { |a| find_attachment_possibly_replaced(a) }
end
end
def find_attachment_possibly_replaced(att)
# if they found a deleted attachment by id, but there's an available
# attachment in the same context and the same full path, we return that
# instead, to emulate replacing a file without having to update every
# by-id reference in every user content field.
if self.respond_to?(:proxy_association)
owner = proxy_association.owner
end
if att.deleted? && owner
new_att = owner.attachments.where(id: att.replacement_attachment_id).first if att.replacement_attachment_id
new_att ||= Folder.find_attachment_in_context_with_path(owner, att.full_display_path)
new_att || att
else
att
end
end
end
RELATIVE_CONTEXT_TYPES = %w(Course Group User Account)
# returns true if the context is a type that supports relative file paths
def self.relative_context?(context_class)
RELATIVE_CONTEXT_TYPES.include?(context_class.to_s)
end
def touch_context_if_appropriate
unless context_type == 'ConversationMessage'
connection.after_transaction_commit { touch_context }
end
end
def before_attachment_saved
run_before_attachment_saved
end
def after_attachment_saved
run_after_attachment_saved
end
before_attachment_saved :run_before_attachment_saved
after_attachment_saved :run_after_attachment_saved
def run_before_attachment_saved
@after_attachment_saved_workflow_state = self.workflow_state
self.workflow_state = 'unattached'
end
# this is a magic method that gets run by attachment-fu after it is done sending to s3,
# note, that the time it takes to send to s3 is the bad guy.
# It blocks and makes the user wait.
def run_after_attachment_saved
if workflow_state == 'unattached' && @after_attachment_saved_workflow_state
self.workflow_state = @after_attachment_saved_workflow_state
@after_attachment_saved_workflow_state = nil
end
if %w(pending_upload processing).include?(workflow_state)
# we don't call .process here so that we don't have to go through another whole save cycle
self.workflow_state = 'processed'
end
# directly update workflow_state so we don't trigger another save cycle
if self.workflow_state_changed?
self.shard.activate do
self.class.where(:id => self).update_all(:workflow_state => self.workflow_state)
end
end
# try an infer encoding if it would be useful to do so
send_later(:infer_encoding) if self.encoding.nil? && self.content_type =~ /text/ && self.context_type != 'SisBatch'
if respond_to?(:process_attachment_with_processing, true) && thumbnailable? && !attachment_options[:thumbnails].blank? && parent_id.nil?
self.class.attachment_options[:thumbnails].each { |suffix, size| send_later_if_production(:create_thumbnail_size, suffix) }
end
end
def infer_encoding
return unless self.encoding.nil?
begin
Iconv.open('UTF-8', 'UTF-8') do |iconv|
self.open do |chunk|
iconv.iconv(chunk)
end
iconv.iconv(nil)
end
self.encoding = 'UTF-8'
Attachment.where(:id => self).update_all(:encoding => 'UTF-8')
rescue Iconv::Failure
self.encoding = ''
Attachment.where(:id => self).update_all(:encoding => '')
return
rescue IOError => e
logger.error("Error inferring encoding for attachment #{self.global_id}: #{e.message}")
end
end
# this is here becase attachment_fu looks to make sure that parent_id is nil before it will create a thumbnail of something.
# basically, it makes a false assumption that the thumbnail class is the same as the original class
# which in our case is false because we use the Thumbnail model for the thumbnails.
def parent_id;end
attr_accessor :clone_updated
def clone_for(context, dup=nil, options={})
if !self.cloned_item && !self.new_record?
self.cloned_item ||= ClonedItem.create(:original_item => self)
self.save!
end
existing = context.attachments.active.find_by_id(self)
existing ||= self.cloned_item_id ? context.attachments.active.where(cloned_item_id: self.cloned_item_id).first : nil
return existing if existing && !options[:overwrite] && !options[:force_copy]
existing ||= self.cloned_item_id ? context.attachments.where(cloned_item_id: self.cloned_item_id).first : nil
dup ||= Attachment.new
dup = existing if existing && options[:overwrite]
dup.assign_attributes(self.attributes.except(*EXCLUDED_COPY_ATTRIBUTES), :without_protection => true)
dup.write_attribute(:filename, self.filename)
# avoid cycles (a -> b -> a) and self-references (a -> a) in root_attachment_id pointers
if dup.new_record? || ![self.id, self.root_attachment_id].include?(dup.id)
dup.root_attachment_id = self.root_attachment_id || self.id
end
dup.context = context
dup.migration_id = CC::CCHelper.create_key(self)
if context.respond_to?(:log_merge_result)
context.log_merge_result("File \"#{dup.folder && dup.folder.full_name}/#{dup.display_name}\" created")
end
dup.updated_at = Time.now
dup.clone_updated = true
dup.set_publish_state_for_usage_rights unless self.locked?
dup
end
def ensure_media_object
return true if self.class.skip_media_object_creation?
in_the_right_state = self.file_state == 'available' && self.workflow_state !~ /^unattached/
if in_the_right_state && self.media_entry_id == 'maybe' &&
self.content_type && self.content_type.match(/\A(video|audio)/)
build_media_object
end
end
def build_media_object
tag = 'add_media_files'
delay = Setting.get('attachment_build_media_object_delay_seconds', 10.to_s).to_i
progress = Progress.where(context_type: 'Attachment', context_id: self, tag: tag).last
progress ||= Progress.new context: self, tag: tag
if progress.new_record?
progress.reset!
progress.process_job(MediaObject, :add_media_files, { :run_at => delay.seconds.from_now, :priority => Delayed::LOWER_PRIORITY, :preserve_method_args => true, :max_attempts => 5 }, self, false) && true
else
progress.completed? && !progress.failed?
end
end
def assert_attachment
if !self.to_be_zipped? && !self.zipping? && !self.errored? && !self.deleted? && (!filename || !content_type || !downloadable?)
self.errors.add(:base, t('errors.not_found', "File data could not be found"))
return false
end
end
after_create :flag_as_recently_created
attr_accessor :recently_created
validates_presence_of :context_id, :context_type, :workflow_state
# related_attachments: our root attachment, anyone who shares our root attachment,
# and anyone who calls us a root attachment
def related_attachments
if root_attachment_id
Attachment.where("id=? OR root_attachment_id=? OR (root_attachment_id=? AND id<>?)",
root_attachment_id, id, root_attachment_id, id)
else
Attachment.where(:root_attachment_id => id)
end
end
def turnitinable?
self.content_type && [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/pdf',
'text/plain',
'text/html',
'application/rtf',
'text/richtext',
'application/vnd.wordperfect'
].include?(self.content_type)
end
def flag_as_recently_created
@recently_created = true
end
protected :flag_as_recently_created
def recently_created?
@recently_created || (self.created_at && self.created_at > Time.now - (60*5))
end
def after_extension
res = self.extension[1..-1] rescue nil
res = nil if res == "" || res == "unknown"
res
end
def assert_file_extension
self.content_type = nil if self.content_type && (self.content_type == 'application/x-unknown' || self.content_type.match(/ERROR/))
self.content_type ||= self.mimetype(self.filename)
if self.filename && self.filename.split(".").length < 2
# we actually have better luck assuming zip files without extensions
# are docx files than assuming they're zip files
self.content_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' if self.content_type.match(/zip/)
ext = self.extension
self.write_attribute(:filename, self.filename + ext) unless ext == '.unknown'
end
end
def extension
res = (self.filename || "").match(/(\.[^\.]*)\z/).to_s
res = nil if res == ""
if !res || res == ""
res = File.mime_types[self.content_type].to_s rescue nil
res = "." + res if res
end
res = nil if res == "."
res ||= ".unknown"
res.to_s
end
def self.clear_cached_mime_ids
@@mime_ids = {}
end
def default_values
self.modified_at = Time.now.utc if self.modified_at.nil?
self.display_name = nil if self.display_name && self.display_name.empty?
self.display_name ||= unencoded_filename
self.file_state ||= "available"
self.last_unlock_at = self.unlock_at if self.unlock_at
self.last_lock_at = self.lock_at if self.lock_at
self.assert_file_extension
self.folder_id = nil if !self.folder || self.folder.context != self.context
self.folder_id = nil if self.folder && self.folder.deleted? && !self.deleted?
self.folder_id ||= Folder.unfiled_folder(self.context).id rescue nil
self.folder_id ||= Folder.root_folders(context).first.id rescue nil
if self.root_attachment && self.new_record?
[:md5, :size, :content_type].each do |key|
self.send("#{key}=", self.root_attachment.send(key))
end
self.workflow_state = 'processed'
self.write_attribute(:filename, self.root_attachment.filename)
end
self.context = self.folder.context if self.folder && (!self.context || (self.context.respond_to?(:is_a_context? ) && self.context.is_a_context?))
if self.respond_to?(:namespace=) && self.new_record?
self.namespace = infer_namespace
end
self.media_entry_id ||= 'maybe' if self.new_record? && self.previewable_media?
end
protected :default_values
def root_account_id
# see note in infer_namespace below
splits = namespace.try(:split, /_/)
return nil if splits.blank?
if splits[1] == "localstorage"
splits[3].to_i
else
splits[1].to_i
end
end
def namespace
read_attribute(:namespace) || (new_record? ? write_attribute(:namespace, infer_namespace) : nil)
end
def infer_namespace
# If you are thinking about changing the format of this, take note: some
# code relies on the namespace as a hacky way to efficiently get the
# attachment's account id. Look for anybody who is accessing namespace and
# splitting the string, etc.
#
# I've added the root_account_id accessor above, but I didn't verify there
# isn't any code still accessing the namespace for the account id directly.
ns = root_attachment.try(:namespace) if root_attachment_id
ns ||= Attachment.domain_namespace
ns ||= self.context.root_account.file_namespace rescue nil
ns ||= self.context.account.file_namespace rescue nil
if Rails.env.development? && Attachment.local_storage?
ns ||= ""
ns = "_localstorage_/#{ns}" unless ns.start_with?('_localstorage_/')
end
ns = nil if ns && ns.empty?
ns
end
def change_namespace(new_namespace)
raise "change_namespace must be called on a root attachment" if self.root_attachment
return if new_namespace == self.namespace
old_full_filename = self.full_filename
write_attribute(:namespace, new_namespace)
self.store.change_namespace(old_full_filename)
self.save
Attachment.where(:root_attachment_id => self).update_all(:namespace => new_namespace)
end
def process_s3_details!(details)
unless workflow_state == 'unattached_temporary'
self.workflow_state = nil
self.file_state = 'available'
end
self.md5 = (details[:etag] || "").gsub(/\"/, '')
self.content_type = details[:content_type]
self.size = details[:content_length]
self.shard.activate do
if existing_attachment = find_existing_attachment_for_md5
if existing_attachment.s3object.exists?
# deduplicate. the existing attachment's s3object should be the same as
# that just uploaded ('cuz md5 match). delete the new copy and just
# have this attachment inherit from the existing attachment.
s3object.delete rescue nil
self.root_attachment = existing_attachment
write_attribute(:filename, nil)
else
# it looks like we had a duplicate, but the existing attachment doesn't
# actually have an s3object (probably from an earlier bug). update it
# and all its inheritors to inherit instead from this attachment.
existing_attachment.root_attachment = self
existing_attachment.write_attribute(:filename, nil)
existing_attachment.save!
Attachment.where(root_attachment_id: existing_attachment).update_all(
root_attachment_id: self,
filename: nil,
updated_at: Time.zone.now)
end
end
save!
# normally this would be called by attachment_fu after it had uploaded the file to S3.
after_attachment_saved
end
end
CONTENT_LENGTH_RANGE = 10.gigabytes
S3_EXPIRATION_TIME = 30.minutes
def ajax_upload_params(pseudonym, local_upload_url, s3_success_url, options = {})
# Build the data that will be needed for the user to upload to s3
# without us being the middle-man
sanitized_filename = full_filename.gsub(/\+/, " ")
policy = {
'expiration' => (options[:expiration] || S3_EXPIRATION_TIME).from_now.utc.iso8601,
'conditions' => [
{'key' => sanitized_filename},
{'acl' => 'private'},
['starts-with', '$Filename', ''],
['content-length-range', 1, (options[:max_size] || CONTENT_LENGTH_RANGE)]
]
}
res = self.store.initialize_ajax_upload_params(local_upload_url, s3_success_url, options)
policy = self.store.amend_policy_conditions(policy, pseudonym)
if res[:upload_params]['folder'].present?
policy['conditions'] << ['starts-with', '$folder', '']
end
extras = []
if options[:no_redirect]
extras << {'success_action_status' => '201'}
extras << {'success_url' => res[:success_url]}
elsif res[:success_url]
extras << {'success_action_redirect' => res[:success_url]}
end
if content_type && content_type != "unknown/unknown"
extras << {'content-type' => content_type}
elsif options[:default_content_type]
extras << {'content-type' => options[:default_content_type]}
end
policy['conditions'] += extras
policy_encoded = Base64.encode64(policy.to_json).gsub(/\n/, '')
signature = Base64.encode64(
OpenSSL::HMAC.digest(
OpenSSL::Digest.new('sha1'), shared_secret, policy_encoded
)
).gsub(/\n/, '')
res[:id] = id
res[:upload_params].merge!({
'Filename' => '',
'key' => sanitized_filename,
'acl' => 'private',
'Policy' => policy_encoded,
'Signature' => signature,
})
extras.map(&:to_a).each{ |extra| res[:upload_params][extra.first.first] = extra.first.last }
res
end
def self.decode_policy(policy_str, signature_str)
return nil if policy_str.blank? || signature_str.blank?
signature = Base64.decode64(signature_str)
return nil if OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha1"), self.shared_secret, policy_str) != signature
policy = JSON.parse(Base64.decode64(policy_str))
return nil unless Time.zone.parse(policy['expiration']) >= Time.now
attachment = Attachment.find(policy['attachment_id'])
return nil unless attachment.try(:state) == :unattached
return policy, attachment
end
def unencoded_filename
CGI::unescape(self.filename || t(:default_filename, "File"))
end
def quota_exemption_key
assign_uuid
Canvas::Security.hmac_sha1(uuid + "quota_exempt")[0,10]
end
def verify_quota_exemption_key(hmac)
Canvas::Security.verify_hmac_sha1(hmac, uuid + "quota_exempt", truncate: 10)
end
def self.minimum_size_for_quota
Setting.get('attachment_minimum_size_for_quota', '512').to_i
end
def self.get_quota(context)
quota = 0
quota_used = 0
context = context.quota_context if context.respond_to?(:quota_context) && context.quota_context
if context
Shackles.activate(:slave) do
quota = Setting.get('context_default_quota', 50.megabytes.to_s).to_i
quota = context.quota if (context.respond_to?("quota") && context.quota)
min = self.minimum_size_for_quota
# translated to ruby this is [size, min].max || 0
quota_used = context.attachments.active.where(root_attachment_id: nil).sum("COALESCE(CASE when size < #{min} THEN #{min} ELSE size END, 0)").to_i
end
end
{:quota => quota, :quota_used => quota_used}
end
# Returns a boolean indicating whether the given context is over quota
# If additional_quota > 0, that'll be added to the current quota used
# (for example, to check if a new attachment of size additional_quota would
# put the context over quota.)
def self.over_quota?(context, additional_quota = nil)
quota = self.get_quota(context)
return quota[:quota] < quota[:quota_used] + (additional_quota || 0)
end
def handle_duplicates(method, opts = {})
return [] unless method.present? && self.folder
method = method.to_sym
deleted_attachments = []
if method == :rename
self.save! unless self.id
valid_name = false
self.shard.activate do
while !valid_name
existing_names = self.folder.active_file_attachments.where("id <> ?", self.id).pluck(:display_name)
new_name = opts[:name] || self.display_name
self.display_name = Attachment.make_unique_filename(new_name, existing_names)
if Attachment.where("id = ? AND NOT EXISTS (SELECT 1 FROM attachments WHERE id <> ? AND display_name = ? AND folder_id = ? AND file_state <> ?)",
self.id, self.id, self.display_name, self.folder_id, 'deleted').limit(1).update_all(:display_name => self.display_name) > 0
valid_name = true
end
end
end
elsif method == :overwrite
atts = self.folder.active_file_attachments.where("display_name=? AND id<>?", self.display_name, self.id)
atts.update_all(:replacement_attachment_id => self) # so we can find the new file in content links
atts.each do |a|
# update content tags to refer to the new file
ContentTag.where(:content_id => a, :content_type => 'Attachment').update_all(:content_id => self)
# update replacement pointers pointing at the overwritten file
context.attachments.where(:replacement_attachment_id => a).update_all(:replacement_attachment_id => self)
# delete the overwritten file (unless the caller is queueing them up)
a.destroy unless opts[:caller_will_destroy]
deleted_attachments << a
end
end
return deleted_attachments
end
def self.destroy_files(ids)
Attachment.where(id: ids).each(&:destroy)
end
before_save :assign_uuid
def assign_uuid
self.uuid ||= CanvasSlug.generate_securish_uuid
end
protected :assign_uuid
def inline_content?
self.content_type.match(/\Atext/) || self.extension == '.html' || self.extension == '.htm' || self.extension == '.swf'
end
def self.shared_secret
raise 'Cannot call Attachment.shared_secret when configured for s3 storage' if s3_storage?
"local_storage" + Canvas::Security.encryption_key
end
def shared_secret
store.shared_secret
end
def downloadable?
!!(self.authenticated_s3_url rescue false)
end
def local_storage_path
"#{HostUrl.context_host(context)}/#{context_type.underscore.pluralize}/#{context_id}/files/#{id}/download?verifier=#{uuid}"
end
def content_type_with_encoding
encoding.blank? ? content_type : "#{content_type}; charset=#{encoding}"
end
# Returns an IO-like object containing the contents of the attachment file.
# Any resources are guaranteed to be cleaned up when the object is garbage
# collected (for instance, using the Tempfile class). Calling close on the
# object may clean up things faster.
#
# By default, this method will stream the file as it is read, if it's stored
# remotely and streaming is possible. If opts[:need_local_file] is true,
# then a local Tempfile will be created if necessary and the IO object
# returned will always respond_to :path and :rewind, and have the right file
# extension.
#
# Be warned! If local storage is used, a File handle to the actual file will
# be returned, not a Tempfile handle. So don't rm the file's .path or
# anything crazy like that. If you need to test whether you can move the file
# at .path, or if you need to copy it, check if the file is_a?(Tempfile) (and
# pass :need_local_file => true of course).
#
# If opts[:temp_folder] is given, and a local temporary file is created, this
# path will be used instead of the default system temporary path. It'll be
# created if necessary.
def open(opts = {}, &block)
store.open(opts, &block)
end
# you should be able to pass an optional width, height, and page_number/video_seconds to this method
# can't handle arbitrary thumbnails for our attachment_fu thumbnails on s3 though, we could handle a couple *predefined* sizes though
def thumbnail_url(options={})
return nil if Attachment.skip_thumbnails
geometry = options[:size]
if self.thumbnail || geometry.present?
to_use = thumbnail_for_size(geometry) || self.thumbnail
to_use.cached_s3_url
elsif self.media_object && self.media_object.media_id
CanvasKaltura::ClientV3.new.thumbnail_url(self.media_object.media_id,
:width => options[:width] || 140,
:height => options[:height] || 100,
:vid_sec => options[:video_seconds] || 5)
else
# "still need to handle things that are not images with thumbnails or kaltura docs"
end
end
def thumbnail_for_size(geometry)
if self.class.allows_thumbnails_of_size?(geometry)
to_use = thumbnails.loaded? ? thumbnails.detect { |t| t.thumbnail == geometry } : thumbnails.where(thumbnail: geometry).first
to_use ||= create_dynamic_thumbnail(geometry)
end
end
def self.allows_thumbnails_of_size?(geometry)
self.dynamic_thumbnail_sizes.include?(geometry)
end
def self.truncate_filename(filename, len, &block)
block ||= lambda { |str, len| str[0...len] }
ext_index = filename.rindex('.')
if ext_index
ext = block.call(filename[ext_index..-1], len / 2 + 1)
base = block.call(filename[0...ext_index], len - ext.length)
base + ext
else
block.call(filename, len)
end
end
alias_method :original_sanitize_filename, :sanitize_filename
def sanitize_filename(filename)
if self.root_attachment && self.root_attachment.filename
filename = self.root_attachment.filename
else
filename = Attachment.truncate_filename(filename, 255) do |component, len|
CanvasTextHelper.cgi_escape_truncate(component, len)
end
end
filename
end
def save_without_broadcasting
begin
@skip_broadcasts = true
save
ensure
@skip_broadcasts = false
end
end
def save_without_broadcasting!
begin
@skip_broadcasts = true
save!
ensure
@skip_broadcasts = false
end
end
# called before save
# notification is not sent until file becomes 'available'
# (i.e., don't notify before it finishes uploading)
def set_need_notify
self.need_notify = true if !@skip_broadcasts &&
file_state_changed? &&
file_state == 'available' &&
context.respond_to?(:state) && context.state == :available &&
folder && folder.visible?
end
# generate notifications for recent file operations
# (this should be run in a delayed job)
def self.do_notifications
# consider a batch complete when no uploads happen in this time
quiet_period = Setting.get("attachment_notify_quiet_period_minutes", "5").to_i.minutes.ago
# if a batch is older than this, just drop it rather than notifying
discard_older_than = Setting.get("attachment_notify_discard_older_than_hours", "120").to_i.hours.ago
while true
file_batches = Attachment.connection.select_rows(sanitize_sql([<<-SQL, quiet_period]))
SELECT COUNT(attachments.id), MIN(attachments.id), MAX(updated_at), context_id, context_type
FROM attachments WHERE need_notify GROUP BY context_id, context_type HAVING MAX(updated_at) < ? LIMIT 500
SQL
break if file_batches.empty?
file_batches.each do |count, attachment_id, last_updated_at, context_id, context_type|
# clear the need_notify flag for this batch
Attachment.where("need_notify AND updated_at <= ? AND context_id = ? AND context_type = ?", last_updated_at, context_id, context_type).
update_all(:need_notify => nil)
# skip the notification if this batch is too old to be timely
next if last_updated_at.to_time < discard_older_than
# now generate the notification
record = Attachment.find(attachment_id)
notification = BroadcastPolicy.notification_finder.by_name(count.to_i > 1 ? 'New Files Added' : 'New File Added')
if record.context.is_a?(Course) && (record.folder.locked? || record.context.tab_hidden?(Course::TAB_FILES))
# only notify course students if they are able to access it
to_list = record.context.participating_admins - [record.user]
elsif record.context.respond_to?(:participants)
to_list = record.context.participants - [record.user]
end
recipient_keys = (to_list || []).compact.map(&:asset_string)
next if recipient_keys.empty?
asset_context = record.context
data = { :count => count }
DelayedNotification.send_later_if_production_enqueue_args(
:process,
{ :priority => Delayed::LOW_PRIORITY },
record, notification, recipient_keys, asset_context, data)
end
end
end
def infer_display_name
self.display_name ||= unencoded_filename
end
protected :infer_display_name
def readable_size
h = ActionView::Base.new
h.extend ActionView::Helpers::NumberHelper
h.number_to_human_size(self.size) rescue "size unknown"
end
def download_url
authenticated_s3_url(:expires => url_ttl, :response_content_disposition => "attachment; " + disposition_filename)
end
def inline_url
authenticated_s3_url(:expires => url_ttl, :response_content_disposition => "inline; " + disposition_filename)
end
def url_ttl
Setting.get('attachment_url_ttl', 1.day.to_s).to_i
end
protected :url_ttl
def disposition_filename
ascii_filename = Iconv.conv("ASCII//TRANSLIT//IGNORE", "UTF-8", display_name)
# response-content-disposition will be url encoded in the depths of
# aws-s3, doesn't need to happen here. we'll be nice and ghetto http
# quote the filename string, though.
quoted_ascii = ascii_filename.gsub(/([\x00-\x1f"\x7f])/, '\\\\\\1')
# awesome browsers will use the filename* and get the proper unicode filename,
# everyone else will get the sanitized ascii version of the filename
quoted_unicode = "UTF-8''#{URI.escape(display_name, /[^A-Za-z0-9.]/)}"
%(filename="#{quoted_ascii}"; filename*=#{quoted_unicode})
end
protected :disposition_filename
def attachment_path_id
a = (self.respond_to?(:root_attachment) && self.root_attachment) || self
((a.respond_to?(:parent_id) && a.parent_id) || a.id).to_s
end
def filename
read_attribute(:filename) || (self.root_attachment && self.root_attachment.filename)
end
def filename=(name)
# infer a display name without round-tripping through truncated CGI-escaped filename
# (which reduces the length of unicode filenames to as few as 28 characters)
self.display_name ||= Attachment.truncate_filename(name, 255)
super(name)
end
def thumbnail_with_root_attachment
self.thumbnail_without_root_attachment || self.root_attachment.try(:thumbnail)
end
alias_method_chain :thumbnail, :root_attachment
def content_directory
self.directory_name || Folder.root_folders(self.context).first.name
end
def to_atom(opts={})
Atom::Entry.new do |entry|
entry.title = t(:feed_title, "File: %{title}", :title => self.context.name) unless opts[:include_context]
entry.title = t(:feed_title_with_context, "File, %{course_or_group}: %{title}", :course_or_group => self.context.name, :title => self.context.name) if opts[:include_context]
entry.authors << Atom::Person.new(:name => self.context.name)
entry.updated = self.updated_at
entry.published = self.created_at
entry.id = "tag:#{HostUrl.default_host},#{self.created_at.strftime("%Y-%m-%d")}:/files/#{self.feed_code}"
entry.links << Atom::Link.new(:rel => 'alternate',
:href => "http://#{HostUrl.context_host(self.context)}/#{context_url_prefix}/files/#{self.id}")
entry.content = Atom::Content::Html.new("#{self.display_name}")
end
end
def name
display_name
end
def title
display_name
end
def associate_with(context)
self.attachment_associations.create(:context => context)
end
def mime_class
{
'text/html' => 'html',
"text/x-csharp" => "code",
"text/xml" => "code",
"text/css" => 'code',
"text" => "text",
"text/plain" => "text",
"application/rtf" => "doc",
"text/rtf" => "doc",
"application/vnd.oasis.opendocument.text" => "doc",
"application/pdf" => "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "doc",
"application/x-docx" => "doc",
"application/msword" => "doc",
"application/vnd.ms-powerpoint" => "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => "ppt",
"application/vnd.ms-excel" => "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xls",
"application/vnd.oasis.opendocument.spreadsheet" => "xls",
"image/jpeg" => "image",
"image/pjpeg" => "image",
"image/png" => "image",
"image/gif" => "image",
"image/x-psd" => "image",
"application/x-rar" => "zip",
"application/x-rar-compressed" => "zip",
"application/x-zip" => "zip",
"application/x-zip-compressed" => "zip",
"application/xml" => "code",
"application/zip" => "zip",
"audio/mpeg" => "audio",
"audio/basic" => "audio",
"audio/mid" => "audio",
"audio/3gpp" => "audio",
"audio/x-aiff" => "audio",
"audio/x-mpegurl" => "audio",
"audio/x-pn-realaudio" => "audio",
"audio/x-wav" => "audio",
"video/mpeg" => "video",
"video/quicktime" => "video",
"video/x-la-asf" => "video",
"video/x-ms-asf" => "video",
"video/x-msvideo" => "video",
"video/x-sgi-movie" => "video",
"video/3gpp" => "video",
"video/mp4" => "video",
"application/x-shockwave-flash" => "flash"
}[content_type] || "file"
end
set_policy do
given { |user, session| self.context.grants_right?(user, session, :manage_files) } #admins.include? user }
can :read and can :update and can :delete and can :create and can :download
given { |user| self.public? }
can :read and can :download
given { |user, session| self.context.grants_right?(user, session, :read) } #students.include? user }
can :read
given { |user, session|
self.context.grants_right?(user, session, :read) &&
(self.context.grants_right?(user, session, :manage_files) || !self.locked_for?(user))
}
can :download
given { |user, session| self.context_type == 'Submission' && self.context.grant_right?(user, session, :comment) }
can :create
given { |user, session|
session && session['file_access_user_id'].present? &&
(u = User.where(id: session['file_access_user_id']).first) &&
(self.context.grants_right?(u, session, :read) ||
(self.context.respond_to?(:is_public_to_auth_users?) && self.context.is_public_to_auth_users?)) &&
session['file_access_expiration'] && session['file_access_expiration'].to_i > Time.now.to_i
}
can :read
given { |user, session|
session && session['file_access_user_id'].present? &&
(u = User.where(id: session['file_access_user_id']).first) &&
(self.context.grants_right?(u, session, :read) ||
(self.context.respond_to?(:is_public_to_auth_users?) && self.context.is_public_to_auth_users?)) &&
(self.context.grants_right?(u, session, :manage_files) || !self.locked_for?(u)) &&
session['file_access_expiration'] && session['file_access_expiration'].to_i > Time.now.to_i
}
can :download
given { |user|
owner = self.user
context_type == 'Assignment' && user == owner
}
can :attach_to_submission_comment
end
# checking if an attachment is locked is expensive and pointless for
# submission attachments
attr_writer :skip_submission_attachment_lock_checks
def locked_for?(user, opts={})
return false if @skip_submission_attachment_lock_checks
return false if opts[:check_policies] && self.grants_right?(user, :update)
return {:asset_string => self.asset_string, :manually_locked => true} if self.locked || (self.folder && self.folder.locked?)
Rails.cache.fetch(locked_cache_key(user), :expires_in => 1.minute) do
locked = false
if (self.unlock_at && Time.now < self.unlock_at)
locked = {:asset_string => self.asset_string, :unlock_at => self.unlock_at}
elsif (self.lock_at && Time.now > self.lock_at)
locked = {:asset_string => self.asset_string, :lock_at => self.lock_at}
elsif self.could_be_locked && item = locked_by_module_item?(user, opts[:deep_check_if_needed])
locked = {:asset_string => self.asset_string, :context_module => item.context_module.attributes}
locked[:unlock_at] = locked[:context_module]["unlock_at"] if locked[:context_module]["unlock_at"]
end
locked
end
end
def hidden?
return @hidden if defined?(@hidden)
@hidden = self.file_state == 'hidden' || (self.folder && self.folder.hidden?)
end
def published?; !locked?; end
def just_hide
self.file_state == 'hidden'
end
def public?
self.file_state == 'public'
end
def currently_locked
self.locked || (self.lock_at && Time.now > self.lock_at) || (self.unlock_at && Time.now < self.unlock_at) || self.file_state == 'hidden'
end
def hidden
hidden?
end
def hidden=(val)
self.file_state = (val == true || val == '1' ? 'hidden' : 'available')
end
def context_module_action(user, action)
self.context_module_tags.each { |tag| tag.context_module_action(user, action) }
end
include Workflow
# Right now, using the state machine to manage whether an attachment has
# been uploaded to Scribd. It can be uploaded to other places, or
# scrubbed in other ways. All that work should be managed by the state
# machine.
workflow do
state :pending_upload do
event :upload, :transitions_to => :processing
event :process, :transitions_to => :processed
event :mark_errored, :transitions_to => :errored
end
state :processing do
event :process, :transitions_to => :processed
event :mark_errored, :transitions_to => :errored
end
state :processed do
event :recycle, :transitions_to => :pending_upload
end
state :errored do
event :recycle, :transitions_to => :pending_upload
end
state :deleted
state :to_be_zipped
state :zipping
state :zipped
state :unattached
state :unattached_temporary
end
scope :visible, -> { where(['attachments.file_state in (?, ?)', 'available', 'public']) }
scope :not_deleted, -> { where("attachments.file_state<>'deleted'") }
scope :not_hidden, -> { where("attachments.file_state<>'hidden'") }
scope :not_locked, -> {
where("(attachments.locked IS NULL OR attachments.locked=?) AND ((attachments.lock_at IS NULL) OR
(attachments.lock_at>? OR (attachments.unlock_at IS NOT NULL AND attachments.unlock_at<?)))", false, Time.now.utc, Time.now.utc)
}
scope :by_content_types, lambda { |types|
clauses = []
types.each do |type|
if type.include? '/'
clauses << sanitize_sql_array(["(attachments.content_type=?)", type])
else
clauses << wildcard('attachments.content_type', type + '/', :type => :right)
end
end
condition_sql = clauses.join(' OR ')
where(condition_sql)
}
alias_method :destroy!, :destroy
# file_state is like workflow_state, which was already taken
# possible values are: available, deleted
def destroy
return if self.new_record?
self.file_state = 'deleted' #destroy
self.deleted_at = Time.now.utc
ContentTag.delete_for(self)
MediaObject.update_all({:attachment_id => nil, :updated_at => Time.now.utc}, {:attachment_id => self.id})
save!
# if the attachment being deleted belongs to a user and the uuid (hash of file) matches the avatar_image_url
# then clear the avatar_image_url value.
self.context.clear_avatar_image_url_with_uuid(self.uuid) if self.context_type == 'User' && self.uuid.present?
end
def restore
self.file_state = 'available'
self.save
end
def deleted?
self.file_state == 'deleted'
end
def available?
self.file_state == 'available'
end
def crocodocable?
Canvas::Crocodoc.enabled? &&
CrocodocDocument::MIME_TYPES.include?(content_type)
end
def canvadocable?
Canvadocs.enabled? && Canvadoc.mime_types.include?(content_type)
end
def self.submit_to_canvadocs(ids)
Attachment.where(id: ids).find_each do |a|
a.submit_to_canvadocs
end
end
def self.skip_3rd_party_submits(skip=true)
@skip_3rd_party_submits = skip
end
def self.skip_3rd_party_submits?
!!@skip_3rd_party_submits
end
def self.skip_media_object_creation(&block)
@skip_media_object_creation = true
block.call
ensure
@skip_media_object_creation = false
end
def self.skip_media_object_creation?
!!@skip_media_object_creation
end
def submit_to_canvadocs(attempt = 1, opts = {})
# ... or crocodoc (this will go away soon)
return if Attachment.skip_3rd_party_submits?
if opts[:wants_annotation] && crocodocable?
# get crocodoc off the canvadocs strand
# (maybe :wants_annotation was a dumb idea)
send_later_enqueue_args :submit_to_crocodoc, {
n_strand: 'crocodoc',
max_attempts: 1,
priority: Delayed::LOW_PRIORITY,
}, attempt
elsif canvadocable?
doc = canvadoc || create_canvadoc
doc.upload
update_attribute(:workflow_state, 'processing')
end
rescue => e
update_attribute(:workflow_state, 'errored')
Canvas::Errors.capture(e, type: :canvadocs, attachment_id: id)
if attempt <= Setting.get('max_canvadocs_attempts', '5').to_i
send_later_enqueue_args :submit_to_canvadocs, {
:n_strand => 'canvadocs_retries',
:run_at => (5 * attempt).minutes.from_now,
:max_attempts => 1,
:priority => Delayed::LOW_PRIORITY,
}, attempt + 1, opts
end
end
def submit_to_crocodoc(attempt = 1)
if crocodocable? && !Attachment.skip_3rd_party_submits?
crocodoc = crocodoc_document || create_crocodoc_document
crocodoc.upload
update_attribute(:workflow_state, 'processing')
end
rescue => e
update_attribute(:workflow_state, 'errored')
Canvas::Errors.capture(e, type: :canvadocs, attachment_id: id)
if attempt <= Setting.get('max_crocodoc_attempts', '5').to_i
send_later_enqueue_args :submit_to_crocodoc, {
:n_strand => 'crocodoc_retries',
:run_at => (5 * attempt).minutes.from_now,
:max_attempts => 1,
:priority => Delayed::LOW_PRIORITY,
}, attempt + 1
end
end
def self.mimetype(filename)
res = nil
res = File.mime_type?(filename) if !res || res == 'unknown/unknown'
res ||= "unknown/unknown"
res
end
def mimetype(fn=nil)
res = Attachment.mimetype(filename)
res = File.mime_type?(self.uploaded_data) if (!res || res == 'unknown/unknown') && self.uploaded_data
res ||= "unknown/unknown"
res
end
def folder_path
if folder
folder.full_name
else
Folder.root_folders(self.context).first.try(:name)
end
end
def full_path
"#{folder_path}/#{filename}"
end
def matches_full_path?(path)
f_path = full_path
f_path == path || URI.unescape(f_path) == path || f_path.downcase == path.downcase || URI.unescape(f_path).downcase == path.downcase
rescue
false
end
def full_display_path
"#{folder_path}/#{display_name}"
end
def matches_full_display_path?(path)
fd_path = full_display_path
fd_path == path || URI.unescape(fd_path) == path || fd_path.downcase == path.downcase || URI.unescape(fd_path).downcase == path.downcase
rescue
false
end
def matches_filename?(match)
filename == match || display_name == match ||
URI.unescape(filename) == match || URI.unescape(display_name) == match ||
filename.downcase == match.downcase || display_name.downcase == match.downcase ||
URI.unescape(filename).downcase == match.downcase || URI.unescape(display_name).downcase == match.downcase
rescue
false
end
def self.attachment_list_from_migration(context, ids)
return "" if !ids || !ids.is_a?(Array) || ids.empty?
description = "<h3>#{ERB::Util.h(t('title.migration_list', "Associated Files"))}</h3><ul>"
ids.each do |id|
attachment = context.attachments.where(migration_id: id).first
description += "<li><a href='/courses/#{context.id}/files/#{attachment.id}/download'>#{ERB::Util.h(attachment.display_name)}</a></li>" if attachment
end
description += "</ul>";
description
end
def self.find_from_path(path, context)
list = path.split("/").select{|f| !f.empty? }
if list[0] != Folder.root_folders(context).first.name
list.unshift(Folder.root_folders(context).first.name)
end
filename = list.pop
folder = context.folder_name_lookups[list.join('/')] rescue nil
folder ||= context.folders.active.where(full_name: list.join('/')).first
context.folder_name_lookups ||= {}
context.folder_name_lookups[list.join('/')] = folder
file = nil
if folder
file = folder.file_attachments.where(filename: filename).first
file ||= folder.file_attachments.where(display_name: filename).first
end
file
end
def self.domain_namespace=(val)
@@domain_namespace = val
end
def self.domain_namespace
@@domain_namespace ||= nil
end
def self.serialization_methods; [:mime_class, :currently_locked, :crocodoc_available?]; end
cattr_accessor :skip_thumbnails
scope :uploadable, -> { where(:workflow_state => 'pending_upload') }
scope :active, -> { where(:file_state => 'available') }
scope :thumbnailable?, -> { where(:content_type => AttachmentFu.content_types) }
scope :by_display_name, -> { order(display_name_order_by_clause('attachments')) }
scope :by_position_then_display_name, -> { order("attachments.position, #{display_name_order_by_clause('attachments')}") }
def self.serialization_excludes; [:uuid, :namespace]; end
# returns filename, if it's already unique, or returns a modified version of
# filename that makes it unique. you can either pass existing_files as string
# filenames, in which case it'll test against those, or a block that'll be
# called repeatedly with a filename until it returns true.
def self.make_unique_filename(filename, existing_files = [], &block)
unless block
block = proc { |fname| !existing_files.include?(fname) }
end
return filename if block.call(filename)
new_name = filename
addition = 1
dir = File.dirname(filename)
dir = dir == "." ? "" : "#{dir}/"
extname = File.extname(filename)
basename = File.basename(filename, extname)
until block.call(new_name = "#{dir}#{basename}-#{addition}#{extname}")
addition += 1
end
new_name
end
DYNAMIC_THUMBNAIL_SIZES = %w(640x>)
# the list of allowed thumbnail sizes to be generated dynamically
def self.dynamic_thumbnail_sizes
DYNAMIC_THUMBNAIL_SIZES + Setting.get("attachment_thumbnail_sizes", "").split(",")
end
def create_dynamic_thumbnail(geometry_string)
tmp = self.create_temp_file
Attachment.unique_constraint_retry do
self.create_or_update_thumbnail(tmp, geometry_string, geometry_string)
end
end
class OverQuotaError < StandardError; end
def clone_url(url, duplicate_handling, check_quota, opts={})
begin
Attachment.clone_url_as_attachment(url, :attachment => self)
if check_quota
self.save! # save to calculate attachment size, otherwise self.size is nil
if Attachment.over_quota?(opts[:quota_context] || self.context, self.size)
raise OverQuotaError, t(:over_quota, 'The downloaded file exceeds the quota.')
end
end
self.file_state = 'available'
self.save!
handle_duplicates(duplicate_handling || 'overwrite')
rescue Exception, Timeout::Error => e
self.file_state = 'errored'
self.workflow_state = 'errored'
case e
when CanvasHttp::TooManyRedirectsError
self.upload_error_message = t :upload_error_too_many_redirects, "Too many redirects"
when CanvasHttp::InvalidResponseCodeError
self.upload_error_message = t :upload_error_invalid_response_code, "Invalid response code, expected 200 got %{code}", :code => e.code
when CanvasHttp::RelativeUriError
self.upload_error_message = t :upload_error_relative_uri, "No host provided for the URL: %{url}", :url => url
when URI::InvalidURIError, ArgumentError
# assigning all ArgumentError to InvalidUri may be incorrect
self.upload_error_message = t :upload_error_invalid_url, "Could not parse the URL: %{url}", :url => url
when Timeout::Error
self.upload_error_message = t :upload_error_timeout, "The request timed out: %{url}", :url => url
when OverQuotaError
self.upload_error_message = t :upload_error_over_quota, "file size exceeds quota limits: %{bytes} bytes", :bytes => self.size
else
self.upload_error_message = t :upload_error_unexpected, "An unknown error occurred downloading from %{url}", :url => url
end
self.save!
end
end
def crocodoc_available?
crocodoc_document.try(:available?)
end
def canvadoc_available?
canvadoc.try(:available?)
end
def view_inline_ping_url
"/#{context_url_prefix}/files/#{self.id}/inline_view"
end
def canvadoc_url(user)
return unless canvadocable?
"/api/v1/canvadoc_session?#{preview_params(user, "canvadoc")}"
end
def crocodoc_url(user)
return unless crocodoc_available?
"/api/v1/crocodoc_session?#{preview_params(user, "crocodoc")}"
end
def previewable_media?
self.content_type && self.content_type.match(/\A(video|audio)/)
end
def preview_params(user, type)
blob = {
user_id: user.try(:global_id),
attachment_id: id,
type: type,
}.to_json
hmac = Canvas::Security.hmac_sha1(blob)
"blob=#{URI.encode blob}&hmac=#{URI.encode hmac}"
end
private :preview_params
def can_unpublish?
false
end
def set_publish_state_for_usage_rights
if self.context &&
self.context.respond_to?(:feature_enabled?) &&
self.context.feature_enabled?(:better_file_browsing) &&
self.context.feature_enabled?(:usage_rights_required)
self.locked = self.usage_rights.nil?
end
end
# Download a URL using a GET request and return a new un-saved Attachment
# with the data at that URL. Tries to detect the correct content_type as
# well.
#
# This handles large files well.
#
# Pass an existing attachment in opts[:attachment] to use that, rather than
# creating a new attachment.
def self.clone_url_as_attachment(url, opts = {})
_, uri = CanvasHttp.validate_url(url)
CanvasHttp.get(url) do |http_response|
if http_response.code.to_i == 200
tmpfile = CanvasHttp.tempfile_for_uri(uri)
# net/http doesn't make this very obvious, but read_body can take any
# object that responds to << as the destination of the body, and it'll
# stream in chunks rather than reading the whole body into memory (as
# long as you use the block form of http.request, which
# CanvasHttp.get does)
http_response.read_body(tmpfile)
tmpfile.rewind
attachment = opts[:attachment] || Attachment.new(:filename => File.basename(uri.path))
attachment.filename ||= File.basename(uri.path)
attachment.uploaded_data = tmpfile
if attachment.content_type.blank? || attachment.content_type == "unknown/unknown"
attachment.content_type = http_response.content_type
end
return attachment
else
raise CanvasHttp::InvalidResponseCodeError.new(http_response.code.to_i)
end
end
end
def self.migrate_attachments(from_context, to_context)
from_attachments = from_context.shard.activate do
Attachment.where(:context_type => from_context.class.name, :context_id => from_context).not_deleted.to_a
end
to_context.shard.activate do
to_attachments = Attachment.where(:context_type => to_context.class.name, :context_id => to_context).not_deleted.to_a
from_attachments.each do |attachment|
match = to_attachments.detect{|a| attachment.matches_full_display_path?(a.full_display_path)}
next if match && match.md5 == attachment.md5
new_attachment = Attachment.new
new_attachment.assign_attributes(attachment.attributes.except(*EXCLUDED_COPY_ATTRIBUTES), :without_protection => true)
new_attachment.context = to_context
new_attachment.folder = Folder.assert_path(attachment.folder_path, to_context)
new_attachment.namespace = new_attachment.infer_namespace
if existing_attachment = new_attachment.find_existing_attachment_for_md5
new_attachment.root_attachment = existing_attachment
else
new_attachment.write_attribute(:filename, attachment.filename)
new_attachment.uploaded_data = attachment.open
end
new_attachment.content_type = attachment.content_type
new_attachment.save_without_broadcasting!
if match
new_attachment.folder.reload
new_attachment.handle_duplicates(:rename)
end
end
end
end
end