class Google::Cloud::Storage::File::SignerV4
@private Create a signed_url for a file.
#
def self.from_bucket bucket, file_name
def self.from_bucket bucket, file_name new bucket.name, file_name, bucket.service end
def self.from_file file
def self.from_file file new file.bucket, file.name, file.service end
def bucket_path path_style
#
def bucket_path path_style "/#{@bucket_name}/" if path_style end
def canonical_and_signed_headers headers, virtual_hosted_style, bucket_bound_hostname
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 = headers_arr.to_h 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
def canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str
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
def determine_expires expires
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
def determine_issuer issuer, client_email
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
def determine_signing_key signing_key, private_key, signer
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
def error_msg attr_name
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
def escape_characters str
methods below are public visibility only for unit testing
def escape_characters str str.chars.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
def escape_query_param str
Only the characters in the regex set [A-Za-z0-9.~_-] must be left un-escaped; all others must be
#
def escape_query_param str CGI.escape(str.to_s).gsub("%7E", "~").gsub "+", "%20" end
def escape_special_unicode str
def escape_special_unicode str str.unpack("U*").map { |i| "\\u#{i.to_s(16).rjust(4, '0')}" }.join end
def ext_url scheme, virtual_hosted_style, bucket_bound_hostname
#
def ext_url scheme, virtual_hosted_style, bucket_bound_hostname url = @service.service.root_url.chomp "/" 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
def file_path path_style
#
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
def generate_signature signing_key, data
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
def host_name virtual_hosted_style, bucket_bound_hostname
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
def initialize bucket_name, file_name, service
def initialize bucket_name, file_name, service @bucket_name = bucket_name @file_name = file_name @service = service end
def issuer_and_signer issuer, client_email, signing_key, private_key, signer
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
def path_style? virtual_hosted_style, bucket_bound_hostname
def path_style? virtual_hosted_style, bucket_bound_hostname !(virtual_hosted_style || bucket_bound_hostname) end
def policy_conditions base_fields, user_conditions, user_fields
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
def post_object issuer: nil,
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
def post_object_ext_path
https://cloud.google.com/storage/docs/xml-api/post-object
"You can also use the ${filename} variable..."
Will not URI encode the special `${filename}` variable.
The external path to the file, URI-encoded.
#
def post_object_ext_path path = "/#{@bucket_name}/#{@file_name}" escaped = Addressable::URI.encode_component path, Addressable::URI::CharacterClasses::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
def post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
#
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
def required_fields issuer, time
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
def service_account_signer signer
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
def signed_url method: "GET",
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
def signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
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