require 'aws-sdk' require 'mime/types' require 'fileutils' module Capistrano module S3 module Publisher LAST_PUBLISHED_FILE = '.last_published' LAST_INVALIDATION_FILE = '.last_invalidation' def self.publish!(region, key, secret, bucket, deployment_path, distribution_id, invalidations, exclusions, only_gzip, extra_options) deployment_path_absolute = File.expand_path(deployment_path, Dir.pwd) s3 = self.establish_s3_client_connection!(region, key, secret) updated = false self.files(deployment_path_absolute, exclusions).each do |file| if !File.directory?(file) next if self.published?(file) next if only_gzip && self.has_gzipped_version?(file) path = self.base_file_path(deployment_path_absolute, file) path.gsub!(/^\//, "") # Remove preceding slash for S3 self.put_object(s3, bucket, path, file, only_gzip, extra_options) end end # invalidate CloudFront distribution if needed if distribution_id && !invalidations.empty? cf = self.establish_cf_client_connection!(region, key, secret) response = cf.create_invalidation({ :distribution_id => distribution_id, :invalidation_batch => { :paths => { :quantity => invalidations.count, :items => invalidations }, :caller_reference => SecureRandom.hex } }) if response && response.successful? File.open(LAST_INVALIDATION_FILE, 'w') { |file| file.write(response[:invalidation][:id]) } end end FileUtils.touch(LAST_PUBLISHED_FILE) end def self.clear!(region, key, secret, bucket) s3 = self.establish_s3_connection!(region, key, secret) s3.buckets[bucket].clear! FileUtils.rm(LAST_PUBLISHED_FILE) FileUtils.rm(LAST_INVALIDATION_FILE) end def self.check_invalidation(region, key, secret, distribution_id) last_invalidation_id = File.read(LAST_INVALIDATION_FILE).strip cf = self.establish_cf_client_connection!(region, key, secret) cf.wait_until(:invalidation_completed, distribution_id: distribution_id, id: last_invalidation_id) do |w| w.max_attempts = nil w.delay = 30 end end private # Establishes the connection to Amazon S3 def self.establish_connection!(klass, region, key, secret) # Send logging to STDOUT Aws.config[:logger] = ::Logger.new(STDOUT) Aws.config[:log_formatter] = Aws::Log::Formatter.colored klass.new( :region => region, :access_key_id => key, :secret_access_key => secret ) end def self.establish_cf_client_connection!(region, key, secret) self.establish_connection!(Aws::CloudFront::Client, region, key, secret) end def self.establish_s3_client_connection!(region, key, secret) self.establish_connection!(Aws::S3::Client, region, key, secret) end def self.establish_s3_connection!(region, key, secret) self.establish_connection!(Aws::S3, region, key, secret) end def self.base_file_path(root, file) file.gsub(root, "") end def self.files(deployment_path, exclusions) Dir.glob("#{deployment_path}/**/*") - Dir.glob(exclusions.map { |e| "#{deployment_path}/#{e}" }) end def self.published?(file) return false unless File.exists? LAST_PUBLISHED_FILE File.mtime(file) < File.mtime(LAST_PUBLISHED_FILE) end def self.put_object(s3, bucket, path, file, only_gzip, extra_options) base_name = File.basename(file) mime_type = mime_type_for_file(base_name) options = { :bucket => bucket, :key => path, :body => open(file), :acl => :public_read, } options.merge!(build_redirect_hash(path, extra_options[:redirect])) options.merge!(extra_options[:write] || {}) if mime_type options.merge!(build_content_type_hash(mime_type)) if mime_type.sub_type == "gzip" options.merge!(build_gzip_content_encoding_hash) options.merge!(build_gzip_content_type_hash(file, mime_type)) # upload as original file name options.merge!(key: self.orig_name(path)) if only_gzip end end s3.put_object(options) end def self.build_redirect_hash(path, redirect_options) return {} unless redirect_options && redirect_options[path] { :website_redirect_location => redirect_options[path] } end def self.build_content_type_hash(mime_type) { :content_type => mime_type.content_type } end def self.build_gzip_content_encoding_hash { :content_encoding => "gzip" } end def self.has_gzipped_version?(file) File.exist?(self.gzip_name(file)) end def self.build_gzip_content_type_hash(file, mime_type) orig_name = self.orig_name(file) orig_mime = mime_type_for_file(orig_name) return {} unless orig_mime && File.exist?(orig_name) { :content_type => orig_mime.content_type } end def self.mime_type_for_file(file) type = MIME::Types.type_for(file) (type && !type.empty?) ? type[0] : nil end def self.gzip_name(file) "#{file}.gz" end def self.orig_name(file) file.sub(/\.gz$/, "") end end end end