class Google::Cloud::Storage::File::SignerV4

@private Create a signed_url for a file.

Public Class Methods

from_bucket(bucket, file_name) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 38
def self.from_bucket bucket, file_name
  new bucket.name, file_name, bucket.service
end
from_file(file) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 34
def self.from_file file
  new file.bucket, file.name, file.service
end
new(bucket_name, file_name, service) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 28
def initialize bucket_name, file_name, service
  @bucket_name = bucket_name
  @file_name = file_name
  @service = service
end

Public Instance Methods

escape_characters(str) click to toggle source

methods below are public visibility only for unit testing rubocop:disable Style/StringLiterals

# File lib/google/cloud/storage/file/signer_v4.rb, line 133
def escape_characters str
  str.split("").map do |s|
    if s.ascii_only?
      case s
      when "\\"
        '\\'
      when "\b"
        '\b'
      when "\f"
        '\f'
      when "\n"
        '\n'
      when "\r"
        '\r'
      when "\t"
        '\t'
      when "\v"
        '\v'
      else
        s
      end
    else
      escape_special_unicode s
    end
  end.join
end
escape_special_unicode(str) click to toggle source

rubocop:enable Style/StringLiterals

# File lib/google/cloud/storage/file/signer_v4.rb, line 161
def escape_special_unicode str
  str.unpack("U*").map { |i| "\\u#{i.to_s(16).rjust(4, '0')}" }.join
end
post_object(issuer: nil, client_email: nil, signing_key: nil, private_key: nil, signer: nil, expires: nil, fields: nil, conditions: nil, scheme: "https", virtual_hosted_style: nil, bucket_bound_hostname: nil) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 42
def post_object issuer: nil,
                client_email: nil,
                signing_key: nil,
                private_key: nil,
                signer: nil,
                expires: nil,
                fields: nil,
                conditions: nil,
                scheme: "https",
                virtual_hosted_style: nil,
                bucket_bound_hostname: nil
  i = determine_issuer issuer, client_email
  s = determine_signing_key signing_key, private_key, signer

  now = Time.now.utc
  base_fields = required_fields i, now
  post_fields = fields.dup || {}
  post_fields.merge! base_fields

  p = {}
  p["conditions"] = policy_conditions base_fields, conditions, fields
  expires ||= 60 * 60 * 24
  p["expiration"] = (now + expires).strftime "%Y-%m-%dT%H:%M:%SZ"

  policy_str = escape_characters p.to_json

  policy = Base64.strict_encode64(policy_str).force_encoding "utf-8"
  signature = generate_signature s, policy

  post_fields["x-goog-signature"] = signature
  post_fields["policy"] = policy
  url = post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
  hostname = "#{url}#{bucket_path path_style?(virtual_hosted_style, bucket_bound_hostname)}"
  Google::Cloud::Storage::PostObject.new hostname, post_fields
end
signed_url(method: "GET", expires: nil, headers: nil, issuer: nil, client_email: nil, signing_key: nil, private_key: nil, signer: nil, query: nil, scheme: "https", virtual_hosted_style: nil, bucket_bound_hostname: nil) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 78
def signed_url method: "GET",
               expires: nil,
               headers: nil,
               issuer: nil,
               client_email: nil,
               signing_key: nil,
               private_key: nil,
               signer: nil,
               query: nil,
               scheme: "https",
               virtual_hosted_style: nil,
               bucket_bound_hostname: nil
  raise ArgumentError, "method is required" unless method
  issuer, signer = issuer_and_signer issuer, client_email, signing_key, private_key, signer
  datetime_now = Time.now.utc
  goog_date = datetime_now.strftime "%Y%m%dT%H%M%SZ"
  datestamp = datetime_now.strftime "%Y%m%d"
  # goog4_request is not checked.
  scope = "#{datestamp}/auto/storage/goog4_request"

  canonical_headers_str, signed_headers_str = canonical_and_signed_headers \
    headers, virtual_hosted_style, bucket_bound_hostname

  algorithm = "GOOG4-RSA-SHA256"
  expires = determine_expires expires
  credential = "#{issuer}/#{scope}"
  canonical_query_str = canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str

  # From AWS: You don't include a payload hash in the Canonical
  # Request, because when you create a presigned URL, you don't know
  # the payload content because the URL is used to upload an arbitrary
  # payload. Instead, you use a constant string UNSIGNED-PAYLOAD.
  payload = headers&.key?("X-Goog-Content-SHA256") ? headers["X-Goog-Content-SHA256"] : "UNSIGNED-PAYLOAD"

  canonical_request = [method,
                       file_path(!(virtual_hosted_style || bucket_bound_hostname)),
                       canonical_query_str,
                       canonical_headers_str,
                       signed_headers_str,
                       payload].join("\n")

  # Construct string to sign
  req_sha = Digest::SHA256.hexdigest canonical_request
  string_to_sign = [algorithm, goog_date, scope, req_sha].join "\n"

  # Sign string
  signature = signer.call string_to_sign

  # Construct signed URL
  hostname = signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
  "#{hostname}?#{canonical_query_str}&X-Goog-Signature=#{signature}"
end

Protected Instance Methods

bucket_path(path_style) click to toggle source

The external path to the bucket, with trailing slash.

# File lib/google/cloud/storage/file/signer_v4.rb, line 297
def bucket_path path_style
  return "/#{@bucket_name}/" if path_style
end
canonical_and_signed_headers(headers, virtual_hosted_style, bucket_bound_hostname) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 236
def canonical_and_signed_headers headers, virtual_hosted_style, bucket_bound_hostname
  if virtual_hosted_style && bucket_bound_hostname
    raise "virtual_hosted_style: #{virtual_hosted_style} and bucket_bound_hostname: " \
          "#{bucket_bound_hostname} params cannot both be passed together"
  end

  canonical_headers = headers || {}
  headers_arr = canonical_headers.map do |k, v|
    [k.downcase, v.strip.gsub(/[^\S\t]+/, " ").gsub(/\t+/, " ")]
  end
  canonical_headers = Hash[headers_arr]
  canonical_headers["host"] = host_name virtual_hosted_style, bucket_bound_hostname

  canonical_headers = canonical_headers.sort_by(&:first).to_h
  canonical_headers_str = canonical_headers.map { |k, v| "#{k}:#{v}\n" }.join
  signed_headers_str = canonical_headers.keys.join ";"
  [canonical_headers_str, signed_headers_str]
end
canonical_query(query, algorithm, credential, goog_date, expires, signed_headers_str) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 263
def canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str
  query ||= {}
  query["X-Goog-Algorithm"] = algorithm
  query["X-Goog-Credential"] = credential
  query["X-Goog-Date"] = goog_date
  query["X-Goog-Expires"] = expires
  query["X-Goog-SignedHeaders"] = signed_headers_str
  query = query.map { |k, v| [escape_query_param(k), escape_query_param(v)] }.sort_by(&:first).to_h
  query.map { |k, v| "#{k}=#{v}" }.join "&"
end
determine_expires(expires) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 255
def determine_expires expires
  expires ||= 604_800 # Default is 7 days.
  if expires > 604_800
    raise ArgumentError, "Expiration time can't be longer than a week"
  end
  expires
end
determine_issuer(issuer, client_email) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 195
def determine_issuer issuer, client_email
  # Parse the Service Account and get client id and private key
  issuer = issuer || client_email || @service.credentials.issuer
  raise SignedUrlUnavailable, error_msg("issuer (client_email)") unless issuer
  issuer
end
determine_signing_key(signing_key, private_key, signer) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 202
def determine_signing_key signing_key, private_key, signer
  signing_key = signing_key || private_key || signer || @service.credentials.signing_key
  raise SignedUrlUnavailable, error_msg("signing_key (private_key, signer)") unless signing_key
  signing_key
end
error_msg(attr_name) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 208
def error_msg attr_name
  "Service account credentials '#{attr_name}' is missing. To generate service account credentials " \
  "see https://cloud.google.com/iam/docs/service-accounts"
end
escape_query_param(str) click to toggle source

Only the characters in the regex set [A-Za-z0-9.~_-] must be left un-escaped; all others must be percent-encoded using %XX UTF-8 style.

# File lib/google/cloud/storage/file/signer_v4.rb, line 277
def escape_query_param str
  CGI.escape(str.to_s).gsub("%7E", "~").gsub "+", "%20"
end
ext_url(scheme, virtual_hosted_style, bucket_bound_hostname) click to toggle source

The external url to the file.

# File lib/google/cloud/storage/file/signer_v4.rb, line 303
def ext_url scheme, virtual_hosted_style, bucket_bound_hostname
  url = GOOGLEAPIS_URL.dup
  if virtual_hosted_style
    parts = url.split "//"
    parts[1] = "#{@bucket_name}.#{parts[1]}"
    parts.join "//"
  elsif bucket_bound_hostname
    raise ArgumentError, "scheme is required" unless scheme
    URI "#{scheme.to_s.downcase}://#{bucket_bound_hostname}"
  else
    url
  end
end
file_path(path_style) click to toggle source

The URI-encoded (percent encoded) external path to the file.

# File lib/google/cloud/storage/file/signer_v4.rb, line 288
def file_path path_style
  path = []
  path << "/#{@bucket_name}" if path_style
  path << "/#{String(@file_name)}" if @file_name && !@file_name.empty?
  CGI.escape(path.join).gsub("%2F", "/").gsub "+", "%20"
end
generate_signature(signing_key, data) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 354
def generate_signature signing_key, data
  packed_signature = nil
  if signing_key.is_a? Proc
    packed_signature = signing_key.call data
  else
    unless signing_key.respond_to? :sign
      signing_key = OpenSSL::PKey::RSA.new signing_key
    end
    packed_signature = signing_key.sign OpenSSL::Digest::SHA256.new, data
  end
  packed_signature.unpack1("H*").force_encoding "utf-8"
end
host_name(virtual_hosted_style, bucket_bound_hostname) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 281
def host_name virtual_hosted_style, bucket_bound_hostname
  return bucket_bound_hostname if bucket_bound_hostname
  virtual_hosted_style ? "#{@bucket_name}.storage.googleapis.com" : "storage.googleapis.com"
end
issuer_and_signer(issuer, client_email, signing_key, private_key, signer) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 229
def issuer_and_signer issuer, client_email, signing_key, private_key, signer
  issuer = determine_issuer issuer, client_email
  signing_key = determine_signing_key signing_key, private_key, signer
  signer = service_account_signer signing_key
  [issuer, signer]
end
path_style?(virtual_hosted_style, bucket_bound_hostname) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 317
def path_style? virtual_hosted_style, bucket_bound_hostname
  !(virtual_hosted_style || bucket_bound_hostname)
end
policy_conditions(base_fields, user_conditions, user_fields) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 176
def policy_conditions base_fields, user_conditions, user_fields
  # Convert each pair in base_fields hash to a single-entry hash in an array.
  conditions = base_fields.to_a.map { |f| Hash[*f] }
  # Add the bucket to the head of the base_fields. This is not returned in the PostObject fields.
  conditions.unshift "bucket" => @bucket_name
  # Add user-provided conditions to the head of the conditions array.
  conditions = user_conditions + conditions if user_conditions
  if user_fields
    # Convert each pair in fields hash to a single-entry hash and add it to the head of the conditions array.
    user_fields.to_a.reverse.each { |f| conditions.unshift Hash[*f] }
  end
  conditions.freeze
end
post_object_ext_path() click to toggle source

The external path to the file, URI-encoded. Will not URI encode the special `${filename}` variable. “You can also use the ${filename} variable…” cloud.google.com/storage/docs/xml-api/post-object

# File lib/google/cloud/storage/file/signer_v4.rb, line 327
def post_object_ext_path
  path = "/#{@bucket_name}/#{@file_name}"
  escaped = Addressable::URI.escape path
  special_var = "${filename}"
  # Restore the unencoded `${filename}` variable, if present.
  if path.include? special_var
    return escaped.gsub "$%7Bfilename%7D", special_var
  end
  escaped
end
post_object_ext_url(scheme, virtual_hosted_style, bucket_bound_hostname) click to toggle source

The external url to the file.

# File lib/google/cloud/storage/file/signer_v4.rb, line 340
def post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
  url = GOOGLEAPIS_URL.dup
  if virtual_hosted_style
    parts = url.split "//"
    parts[1] = "#{@bucket_name}.#{parts[1]}/"
    parts.join "//"
  elsif bucket_bound_hostname
    raise ArgumentError, "scheme is required" unless scheme
    URI "#{scheme.to_s.downcase}://#{bucket_bound_hostname}/"
  else
    url
  end
end
required_fields(issuer, time) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 167
def required_fields issuer, time
  {
    "key" => @file_name,
    "x-goog-date" => time.strftime("%Y%m%dT%H%M%SZ"),
    "x-goog-credential" => "#{issuer}/#{time.strftime '%Y%m%d'}/auto/storage/goog4_request",
    "x-goog-algorithm" => "GOOG4-RSA-SHA256"
  }.freeze
end
service_account_signer(signer) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 213
def service_account_signer signer
  if signer.is_a? Proc
    lambda do |string_to_sign|
      sig = signer.call string_to_sign
      sig.unpack1 "H*"
    end
  else
    signer = OpenSSL::PKey::RSA.new signer unless signer.respond_to? :sign
    # Sign string to sign
    lambda do |string_to_sign|
      sig = signer.sign OpenSSL::Digest::SHA256.new, string_to_sign
      sig.unpack1 "H*"
    end
  end
end
signed_url_hostname(scheme, virtual_hosted_style, bucket_bound_hostname) click to toggle source
# File lib/google/cloud/storage/file/signer_v4.rb, line 190
def signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
  url = ext_url scheme, virtual_hosted_style, bucket_bound_hostname
  "#{url}#{file_path path_style?(virtual_hosted_style, bucket_bound_hostname)}"
end