require 'openssl'
require 'base64'
module Aws
module S3
# @note Normally you do not need to construct a {PresignedPost} yourself.
# See {Bucket#presigned_post} and {Object#presigned_post}.
#
# ## Basic Usage
#
# To generate a presigned post, you need AWS credentials, the region
# your bucket is in, and the name of your bucket. You can apply constraints
# to the post object as options to {#initialize} or by calling
# methods such as {#key} and {#content_length_range}.
#
# The following two examples are equivalent.
#
# ```ruby
# post = Aws::S3::PresignedPost.new(creds, region, bucket, {
# key: '/uploaded/object/key',
# content_length_range: 0..1024,
# acl: 'public-read',
# metadata: {
# 'original-filename' => '${filename}'
# }
# })
# post.fields
# #=> { ... }
#
# post = Aws::S3::PresignedPost.new(creds, region, bucket).
# key('/uploaded/object/key').
# content_length_range(0..1024).
# acl('public-read').
# metadata('original-filename' => '${filename}').
# fields
# #=> { ... }
# ```
#
# ## HTML Forms
#
# You can use a {PresignedPost} object to build an HTML form. It is
# recommended to use some helper to build the form tag and input
# tags that properly escapes values.
#
# ### Form Tag
#
# To upload a file to Amazon S3 using a browser, you need to create
# a post form. The {#url} method returns the value you should use
# as the form action.
#
# ```erb
#
# ```
#
# The follow attributes must be set on the form:
#
# * `action` - This must be the {#url}.
# * `method` - This must be `post`.
# * `enctype` - This must be `multipart/form-data`.
#
# ### Form Fields
#
# The {#fields} method returns a hash of form fields to render inside
# the form. Typically these are rendered as hidden input fields.
#
# ```erb
# <% @post.fields.each do |name, value| %>
#
# <% end %>
# ```
#
# Lastly, the form must have a file field with the name `file`.
#
# ```erb
#
# ```
#
# ## Post Policy
#
# When you construct a {PresignedPost}, you must specify every form
# field name that will be posted by the browser. If you omit a form
# field sent by the browser, Amazon S3 will reject the request.
# You can specify accepted form field values three ways:
#
# * Specify exactly what the value must be.
# * Specify what value the field starts with.
# * Specify the field may have any value.
#
# ### Field Equals
#
# You can specify that a form field must be a certain value.
# Simply pass an option like `:content_type` to the constructor,
# or call the associated method.
#
# ```ruby
# post = Aws::S3::PresignedPost.new(creds, region, bucket).
# post.content_type('text/plain')
# ```
#
# If any of the given values are changed by the user in the form, then
# Amazon S3 will reject the POST request.
#
# ### Field Starts With
#
# You can specify prefix values for many of the POST form fields.
# To specify a required prefix, use the `:_starts_with`
# option or call the associated `#_starts_with` method.
#
# ```ruby
# post = Aws::S3::PresignedPost.new(creds, region, bucket, {
# key_starts_with: '/images/',
# content_type_starts_with: 'image/',
# # ...
# })
# ```
#
# When using starts with, the form must contain a field where the
# user can specify the value. The {PresignedPost} will not add
# a value for these fields.
#
# ### Any Field Value
#
# To white-list a form field to send any value, you can name that
# field with `:allow_any` or {#allow_any}.
#
# ```ruby
# post = Aws::S3::PresignedPost.new(creds, region, bucket, {
# key: 'object-key',
# allow_any: ['Filename'],
# # ...
# })
# ```
#
# ### Metadata
#
# You can add rules for metadata fields using `:metadata`, {#metadata},
# `:metadata_starts_with` and {#metadata_starts_with}. Unlike other
# form fields, you pass a hash value to these options/methods:
#
# ```ruby
# post = Aws::S3::PresignedPost.new(creds, region, bucket).
# key('/fixed/key').
# metadata(foo: 'bar')
#
# post.fields['x-amz-meta-foo']
# #=> 'bar'
# ```
#
# ### The `${filename}` Variable
#
# The string `${filename}` is automatically replaced with the name of the
# file provided by the user and is recognized by all form fields. It is
# not supported with `starts_with` conditions.
#
# If the browser or client provides a full or partial path to the file,
# only the text following the last slash (/) or backslash (\) will be used
# (e.g., "C:\Program Files\directory1\file.txt" will be interpreted
# as "file.txt"). If no file or file name is provided, the variable is
# replaced with an empty string.
#
# In the following example, we use `${filename}` to store the original
# filename in the `x-amz-meta-` hash with the uploaded object.
#
# ```ruby
# post = Aws::S3::PresignedPost.new(creds, region, bucket, {
# key: '/fixed/key',
# metadata: {
# 'original-filename': '${filename}'
# }
# })
# ```
#
class PresignedPost
# @param [Credentials] credentials Security credentials for signing
# the post policy.
# @param [String] bucket_region Region of the target bucket.
# @param [String] bucket_name Name of the target bucket.
# @option options [Time] :signature_expiration Specify when the signature on
# the post will expire. Defaults to one hour from creation of the
# presigned post. May not exceed one week from creation time.
# @option options [String] :key See {PresignedPost#key}.
# @option options [String] :key_starts_with See {PresignedPost#key_starts_with}.
# @option options [String] :acl See {PresignedPost#acl}.
# @option options [String] :acl_starts_with See {PresignedPost#acl_starts_with}.
# @option options [String] :cache_control See {PresignedPost#cache_control}.
# @option options [String] :cache_control_starts_with See {PresignedPost#cache_control_starts_with}.
# @option options [String] :content_type See {PresignedPost#content_type}.
# @option options [String] :content_type_starts_with See {PresignedPost#content_type_starts_with}.
# @option options [String] :content_disposition See {PresignedPost#content_disposition}.
# @option options [String] :content_disposition_starts_with See {PresignedPost#content_disposition_starts_with}.
# @option options [String] :content_encoding See {PresignedPost#content_encoding}.
# @option options [String] :content_encoding_starts_with See {PresignedPost#content_encoding_starts_with}.
# @option options [String] :expires See {PresignedPost#expires}.
# @option options [String] :expires_starts_with See {PresignedPost#expires_starts_with}.
# @option options [Range] :content_length_range See {PresignedPost#content_length_range}.
# @option options [String] :success_action_redirect See {PresignedPost#success_action_redirect}.
# @option options [String] :success_action_redirect_starts_with See {PresignedPost#success_action_redirect_starts_with}.
# @option options [String] :success_action_status See {PresignedPost#success_action_status}.
# @option options [String] :storage_class See {PresignedPost#storage_class}.
# @option options [String] :website_redirect_location See {PresignedPost#website_redirect_location}.
# @option options [Hash] :metadata See {PresignedPost#metadata}.
# @option options [Hash] :metadata_starts_with See {PresignedPost#metadata_starts_with}.
# @option options [String] :server_side_encryption See {PresignedPost#server_side_encryption}.
# @option options [String] :server_side_encryption_aws_kms_key_id See {PresignedPost#server_side_encryption_aws_kms_key_id}.
# @option options [String] :server_side_encryption_customer_algorithm See {PresignedPost#server_side_encryption_customer_algorithm}.
# @option options [String] :server_side_encryption_customer_key See {PresignedPost#server_side_encryption_customer_key}.
def initialize(credentials, bucket_region, bucket_name, options = {})
@credentials = credentials.credentials
@bucket_region = bucket_region
@bucket_name = bucket_name
@url = options.delete(:url) || bucket_url
@fields = {}
@key_set = false
@signature_expiration = Time.now + 3600
@conditions = [{ 'bucket' => @bucket_name }]
options.each do |option_name, option_value|
case option_name
when :allow_any then allow_any(option_value)
when :signature_expiration then @signature_expiration = option_value
else send("#{option_name}", option_value)
end
end
end
# @return [String] The URL to post a file upload to. This should be
# the form action.
attr_reader :url
# @return [Hash] A hash of fields to render in an HTML form
# as hidden input fields.
def fields
check_required_values!
datetime = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
fields = @fields.dup
fields.update('policy' => policy(datetime))
fields.update(signature_fields(datetime))
fields.update('x-amz-signature' => signature(datetime, fields['policy']))
end
# A list of form fields to white-list with any value.
# @param [Sting, Array] field_names
# @return [self]
def allow_any(*field_names)
field_names.flatten.each do |field_name|
@key_set = true if field_name.to_s == 'key'
starts_with(field_name, '')
end
self
end
# @api private
def self.define_field(field, *args)
options = args.last.is_a?(Hash) ? args.pop : {}
field_name = args.last || field.to_s
define_method("#{field}") do |value|
with(field_name, value)
end
if options[:starts_with]
define_method("#{field}_starts_with") do |value|
starts_with(field_name, value)
end
end
end
# @!group Fields
# The key to use for the uploaded object. Use can use `${filename}`
# as a variable in the key. This will be replaced with the name
# of the file as provided by the user.
#
# For example, if the key is given as `/user/betty/${filename}` and
# the file uploaded is named `lolcatz.jpg`, the resultant key will
# be `/user/betty/lolcatz.jpg`.
#
# @param [String] key
# @see http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMetadata.html)
# @return [self]
def key(key)
@key_set = true
with('key', key)
end
# Specify a prefix the uploaded
# @param [String] prefix
# @see #key
# @return [self]
def key_starts_with(prefix)
@key_set = true
starts_with('key', prefix)
end
# @!method acl(canned_acl)
# Specify the cannedl ACL (access control list) for the object.
# May be one of the following values:
#
# * `private`
# * `public-read`
# * `public-read-write`
# * `authenticated-read`
# * `bucket-owner-read`
# * `bucket-owner-full-control`
#
# @param [String] canned_acl
# @see http://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html
# @return [self]
#
# @!method acl_starts_with(prefix)
# @param [String] prefix
# @see #acl
# @return [self]
define_field(:acl, starts_with: true)
# @!method cache_control(value)
# Specify caching behavior along the request/reply chain.
# @param [String] value
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.
# @return [self]
#
# @!method cache_control_starts_with(prefix)
# @param [String] prefix
# @see #cache_control
# @return [self]
define_field(:cache_control, 'Cache-Control', starts_with: true)
# @return [String]
# @!method content_type(value)
# A standard MIME type describing the format of the contents.
# @param [String] value
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.21
# @return [self]
#
# @!method content_type_starts_with(prefix)
# @param [String] prefix
# @see #content_type
# @return [self]
define_field(:content_type, 'Content-Type', starts_with: true)
# @!method content_disposition(value)
# Specifies presentational information for the object.
# @param [String] value
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html#sec19.5.1
# @return [self]
#
# @!method content_disposition_starts_with(prefix)
# @param [String] prefix
# @see #content_disposition
# @return [self]
define_field(:content_disposition, 'Content-Disposition', starts_with: true)
# @!method content_encoding(value)
# Specifies what content encodings have been applied to the object
# and thus what decoding mechanisms must be applied to obtain the
# media-type referenced by the Content-Type header field.
# @param [String] value
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11
# @return [self]
#
# @!method content_encoding_starts_with(prefix)
# @param [String] prefix
# @see #content_encoding
# @return [self]
define_field(:content_encoding, 'Content-Encoding', starts_with: true)
# The date and time at which the object is no longer cacheable.
# @note This does not affect the expiration of the presigned post
# signature.
# @param [Time] time
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.21
# @return [self]
def expires(time)
with('Expires', time.httpdate)
end
# @param [String] prefix
# @see #expires
# @return [self]
def expires_starts_with(prefix)
starts_with('Expires', prefix)
end
# The minimum and maximum allowable size for the uploaded content.
# @param [Range] byte_range
# @return [self]
def content_length_range(byte_range)
min = byte_range.begin
max = byte_range.end
max -= 1 if byte_range.exclude_end?
@conditions << ['content-length-range', min, max]
self
end
# @!method success_action_redirect(value)
# The URL to which the client is redirected
# upon successful upload. If {#success_action_redirect} is not
# specified, Amazon S3 returns the empty document type specified
# by {#success_action_status}.
#
# If Amazon S3 cannot interpret the URL, it acts as if the field
# is not present. If the upload fails, Amazon S3 displays an error
# and does not redirect the user to a URL.
#
# @param [String] value
# @return [self]
#
# @!method success_action_redirect_starts_with(prefix)
# @param [String] prefix
# @see #success_action_redirect
# @return [self]
define_field(:success_action_redirect, starts_with: true)
# @!method success_action_status(value)
# The status code returned to the client upon
# successful upload if {#success_action_redirect} is not
# specified.
#
# Accepts the values `200`, `201`, or `204` (default).
#
# If the value is set to 200 or 204, Amazon S3 returns an empty
# document with a 200 or 204 status code. If the value is set to 201,
# Amazon S3 returns an XML document with a 201 status code.
#
# If the value is not set or if it is set to an invalid value, Amazon
# S3 returns an empty document with a 204 status code.
#
# @param [String] The status code returned to the client upon
# @return [self]
define_field(:success_action_status)
# @!method storage_class(value)
# Storage class to use for storing the object. Defaults to
# `STANDARD`. Must be one of:
#
# * `STANDARD`
# * `REDUCED_REDUNDANCY`
#
# You cannot specify `GLACIER` as the storage class. To transition
# objects to the GLACIER storage class you can use lifecycle
# configuration.
# @param [String] value Storage class to use for storing the
# @return [self]
define_field(:storage_class, 'x-amz-storage-class')
# @!method website_redirect_location(value)
# If the bucket is configured as a website,
# redirects requests for this object to another object in the
# same bucket or to an external URL. Amazon S3 stores this value
# in the object metadata.
#
# The value must be prefixed by, "/", "http://" or "https://".
# The length of the value is limited to 2K.
#
# @param [String] value
# @see http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMetadata.html
# @see http://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteHosting.html
# @see http://docs.aws.amazon.com/AmazonS3/latest/dev/how-to-page-redirect.html
# @return [self]
define_field(:website_redirect_location, 'x-amz-website-redirect-location')
# Metadata hash to store with the uploaded object. Hash keys will be
# prefixed with "x-amz-meta-".
# @param [Hash] hash
# @return [self]
def metadata(hash)
hash.each do |key, value|
with("x-amz-meta-#{key}", value)
end
self
end
# Specify allowable prefix for each key in the metadata hash.
# @param [Hash] hash
# @see #metadata
# @return [self]
def metadata_starts_with(hash)
hash.each do |key, value|
starts_with("x-amz-meta-#{key}", value)
end
self
end
# @!endgroup
# @!group Server-Side Encryption Fields
# @!method server_side_encryption(value)
# Specifies a server-side encryption algorithm to use when Amazon
# S3 creates an object. Valid values include:
#
# * `aws:kms`
# * `AES256`
#
# @param [String] value
# @return [self]
define_field(:server_side_encryption, 'x-amz-server-side-encryption')
# @!method server_side_encryption_aws_kms_key_id(value)
# If {#server_side_encryption} is called with the value of `aws:kms`,
# this method specifies the ID of the AWS Key Management Service
# (KMS) master encryption key to use for the object.
# @param [String] value
# @return [self]
define_field(:server_side_encryption_aws_kms_key_id, 'x-amz-server-side-encryption-aws-kms-key-id')
# @!endgroup
# @!group Server-Side Encryption with Customer-Provided Key Fields
# @!method server_side_encryption_customer_algorithm(value)
# Specifies the algorithm to use to when encrypting the object.
# Must be set to `AES256` when using customer-provided encryption
# keys. Must also call {#server_side_encryption_customer_key}.
# @param [String] value
# @see #server_side_encryption_customer_key
# @return [self]
define_field(:server_side_encryption_customer_algorithm, 'x-amz-server-side-encryption-customer-algorithm')
# Specifies the customer-provided encryption key for Amazon S3 to use
# in encrypting data. This value is used to store the object and then
# it is discarded; Amazon does not store the encryption key.
#
# You must also call {#server_side_encryption_customer_algorithm}.
#
# @param [String] value
# @see #server_side_encryption_customer_algorithm
# @return [self]
def server_side_encryption_customer_key(value)
field_name = 'x-amz-server-side-encryption-customer-key'
with(field_name, base64(value))
with(field_name + '-MD5', base64(OpenSSL::Digest::MD5.digest(value)))
end
# @param [String] prefix
# @see #server_side_encryption_customer_key
# @return [self]
def server_side_encryption_customer_key_starts_with(prefix)
field_name = 'x-amz-server-side-encryption-customer-key'
starts_with(field_name, prefix)
end
# @!endgroup
private
def with(field_name, value)
fvar = '${filename}'
if index = value.rindex(fvar)
if index + fvar.size == value.size
@fields[field_name] = value
starts_with(field_name, value[0,index])
else
msg = "${filename} only supported at the end of #{field_name}"
raise ArgumentError, msg
end
else
@fields[field_name] = value.to_s
@conditions << { field_name => value.to_s }
end
self
end
def starts_with(field_name, value, &block)
@conditions << ['starts-with', "$#{field_name}", value.to_s]
self
end
def check_required_values!
unless @key_set
msg = "key required; you must provide a key via :key, "
msg << ":key_starts_with, or :allow_any => ['key']"
raise msg
end
end
def bucket_url
url = EndpointProvider.resolve(@bucket_region, 's3')
url = URI.parse(url)
if Plugins::S3BucketDns.dns_compatible?(@bucket_name, true)
url.host = @bucket_name + '.' + url.host
else
url.path = '/' + @bucket_name
end
url.to_s
end
# @return [Hash]
def policy(datetime)
check_required_values!
policy = {}
policy['expiration'] = @signature_expiration.utc.iso8601
policy['conditions'] = @conditions.dup
signature_fields(datetime).each do |name, value|
policy['conditions'] << { name => value }
end
base64(Json.dump(policy))
end
def signature_fields(datetime)
fields = {}
fields['x-amz-credential'] = credential_scope(datetime)
fields['x-amz-algorithm'] = 'AWS4-HMAC-SHA256'
fields['x-amz-date'] = datetime
if session_token = @credentials.session_token
fields['x-amz-security-token'] = session_token
end
fields
end
def signature(datetime, string_to_sign)
k_secret = @credentials.secret_access_key
k_date = hmac("AWS4" + k_secret, datetime[0,8])
k_region = hmac(k_date, @bucket_region)
k_service = hmac(k_region, 's3')
k_credentials = hmac(k_service, 'aws4_request')
hexhmac(k_credentials, string_to_sign)
end
def hmac(key, value)
OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, value)
end
def hexhmac(key, value)
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, value)
end
def credential_scope(datetime)
parts = []
parts << @credentials.access_key_id
parts << datetime[0,8]
parts << @bucket_region
parts << 's3'
parts << 'aws4_request'
parts.join('/')
end
def base64(str)
Base64.strict_encode64(str)
end
end
end
end