google-cloud-storage 1.25.0 → 1.27.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -111,7 +111,7 @@ module Google
111
111
  # @param [String,Symbol,Array<String,Symbol>] matches_storage_class
112
112
  # Files having any of the storage classes specified by this
113
113
  # condition will be matched. Values include `STANDARD`, `NEARLINE`,
114
- # and `COLDLINE`. `REGIONAL`,`MULTI_REGIONAL`, and
114
+ # `COLDLINE`, and `ARCHIVE`. `REGIONAL`,`MULTI_REGIONAL`, and
115
115
  # `DURABLE_REDUCED_AVAILABILITY` are supported as legacy storage
116
116
  # classes. Arguments will be converted from symbols and lower-case
117
117
  # to upper-case strings.
@@ -162,7 +162,7 @@ module Google
162
162
  # @param [String,Symbol,Array<String,Symbol>] matches_storage_class
163
163
  # Files having any of the storage classes specified by this
164
164
  # condition will be matched. Values include `STANDARD`, `NEARLINE`,
165
- # and `COLDLINE`. `REGIONAL`,`MULTI_REGIONAL`, and
165
+ # `COLDLINE`, and `ARCHIVE`. `REGIONAL`,`MULTI_REGIONAL`, and
166
166
  # `DURABLE_REDUCED_AVAILABILITY` are supported as legacy storage
167
167
  # classes. Arguments will be converted from symbols and lower-case
168
168
  # to upper-case strings.
@@ -240,9 +240,9 @@ module Google
240
240
  # is `false`, it matches archived files.
241
241
  # @attr [Array<String>] matches_storage_class Files having any of the
242
242
  # storage classes specified by this condition will be matched.
243
- # Values include `STANDARD`, `NEARLINE`, and `COLDLINE`. `REGIONAL`,
244
- # `MULTI_REGIONAL`, and `DURABLE_REDUCED_AVAILABILITY` are supported
245
- # as legacy storage classes.
243
+ # Values include `STANDARD`, `NEARLINE`, `COLDLINE`, and `ARCHIVE`.
244
+ # `REGIONAL`, `MULTI_REGIONAL`, and `DURABLE_REDUCED_AVAILABILITY`
245
+ # are supported as legacy storage classes.
246
246
  # @attr [Integer] num_newer_versions Relevant only for versioned
247
247
  # files. If the value is N, this condition is satisfied when there
248
248
  # are at least N versions (including the live version) newer than
@@ -25,12 +25,13 @@ module Google
25
25
  def storage_class_for str
26
26
  return nil if str.nil?
27
27
  return str.map { |s| storage_class_for s } if str.is_a? Array
28
- { "durable_reduced_availability" => "DURABLE_REDUCED_AVAILABILITY",
28
+ { "archive" => "ARCHIVE",
29
+ "coldline" => "COLDLINE",
29
30
  "dra" => "DURABLE_REDUCED_AVAILABILITY",
30
31
  "durable" => "DURABLE_REDUCED_AVAILABILITY",
31
- "nearline" => "NEARLINE",
32
- "coldline" => "COLDLINE",
32
+ "durable_reduced_availability" => "DURABLE_REDUCED_AVAILABILITY",
33
33
  "multi_regional" => "MULTI_REGIONAL",
34
+ "nearline" => "NEARLINE",
34
35
  "regional" => "REGIONAL",
35
36
  "standard" => "STANDARD" }[str.to_s.downcase] || str.to_s
36
37
  end
@@ -58,8 +58,13 @@ module Google
58
58
  ##
59
59
  # # SignedUrlUnavailable Error
60
60
  #
61
- # This is raised when File#signed_url is unable to generate a URL due to
62
- # missing credentials needed to create the URL.
61
+ # Raised by signed URL methods if the service account credentials
62
+ # are missing. Service account credentials are acquired by following the
63
+ # steps in [Service Account Authentication](
64
+ # https://cloud.google.com/iam/docs/service-accounts).
65
+ #
66
+ # @see https://cloud.google.com/storage/docs/access-control/signed-urls Signed URLs
67
+ #
63
68
  class SignedUrlUnavailable < Google::Cloud::Error
64
69
  end
65
70
  end
@@ -431,6 +431,7 @@ module Google
431
431
  # * `:standard`
432
432
  # * `:nearline`
433
433
  # * `:coldline`
434
+ # * `:archive`
434
435
  #
435
436
  # as well as the equivalent strings returned by {File#storage_class} or
436
437
  # {Bucket#storage_class}. For more information, see [Storage
@@ -1441,7 +1442,7 @@ module Google
1441
1442
  # A {SignedUrlUnavailable} is raised if the service account credentials
1442
1443
  # are missing. Service account credentials are acquired by following the
1443
1444
  # steps in [Service Account Authentication](
1444
- # https://cloud.google.com/storage/docs/authentication#service_accounts).
1445
+ # https://cloud.google.com/iam/docs/service-accounts).
1445
1446
  #
1446
1447
  # @see https://cloud.google.com/storage/docs/access-control/signed-urls
1447
1448
  # Signed URLs guide
@@ -1466,10 +1467,22 @@ module Google
1466
1467
  # use the signed URL.
1467
1468
  # @param [String] issuer Service Account's Client Email.
1468
1469
  # @param [String] client_email Service Account's Client Email.
1469
- # @param [OpenSSL::PKey::RSA, String] signing_key Service Account's
1470
- # Private Key.
1471
- # @param [OpenSSL::PKey::RSA, String] private_key Service Account's
1472
- # Private Key.
1470
+ # @param [OpenSSL::PKey::RSA, String, Proc] signing_key Service Account's
1471
+ # Private Key or a Proc that accepts a single String parameter and returns a
1472
+ # RSA SHA256 signature using a valid Google Service Account Private Key.
1473
+ # @param [OpenSSL::PKey::RSA, String, Proc] private_key Service Account's
1474
+ # Private Key or a Proc that accepts a single String parameter and returns a
1475
+ # RSA SHA256 signature using a valid Google Service Account Private Key.
1476
+ # @param [OpenSSL::PKey::RSA, String, Proc] signer Service Account's
1477
+ # Private Key or a Proc that accepts a single String parameter and returns a
1478
+ # RSA SHA256 signature using a valid Google Service Account Private Key.
1479
+ #
1480
+ # When using this method in environments such as GAE Flexible Environment,
1481
+ # GKE, or Cloud Functions where the private key is unavailable, it may be
1482
+ # necessary to provide a Proc (or lambda) via the signer parameter. This
1483
+ # Proc should return a signature created using a RPC call to the
1484
+ # [Service Account Credentials signBlob](https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/signBlob)
1485
+ # method as shown in the example below.
1473
1486
  # @param [Hash] query Query string parameters to include in the signed
1474
1487
  # URL. The given parameters are not verified by the signature.
1475
1488
  #
@@ -1478,11 +1491,29 @@ module Google
1478
1491
  # using the URL, but only when the file resource is missing the
1479
1492
  # corresponding values. (These values can be permanently set using
1480
1493
  # {#content_disposition=} and {#content_type=}.)
1494
+ # @param [String] scheme The URL scheme. The default value is `HTTPS`.
1495
+ # @param [Boolean] virtual_hosted_style Whether to use a virtual hosted-style
1496
+ # hostname, which adds the bucket into the host portion of the URI rather
1497
+ # than the path, e.g. `https://mybucket.storage.googleapis.com/...`.
1498
+ # For V4 signing, this also sets the `host` header in the canonicalized
1499
+ # extension headers to the virtual hosted-style host, unless that header is
1500
+ # supplied via the `headers` param. The default value of `false` uses the
1501
+ # form of `https://storage.googleapis.com/mybucket`.
1502
+ # @param [String] bucket_bound_hostname Use a bucket-bound hostname, which
1503
+ # replaces the `storage.googleapis.com` host with the name of a `CNAME`
1504
+ # bucket, e.g. a bucket named `gcs-subdomain.my.domain.tld`, or a Google
1505
+ # Cloud Load Balancer which routes to a bucket you own, e.g.
1506
+ # `my-load-balancer-domain.tld`.
1481
1507
  # @param [Symbol, String] version The version of the signed credential
1482
1508
  # to create. Must be one of `:v2` or `:v4`. The default value is
1483
1509
  # `:v2`.
1484
1510
  #
1485
- # @return [String]
1511
+ # @return [String] The signed URL.
1512
+ #
1513
+ # @raise [SignedUrlUnavailable] If the service account credentials
1514
+ # are missing. Service account credentials are acquired by following the
1515
+ # steps in [Service Account Authentication](
1516
+ # https://cloud.google.com/iam/docs/service-accounts).
1486
1517
  #
1487
1518
  # @example
1488
1519
  # require "google/cloud/storage"
@@ -1502,7 +1533,7 @@ module Google
1502
1533
  # file = bucket.file "avatars/heidi/400x400.png"
1503
1534
  # shared_url = file.signed_url expires: 300, # 5 minutes from now
1504
1535
  # version: :v4
1505
-
1536
+ #
1506
1537
  # @example Using the `issuer` and `signing_key` options:
1507
1538
  # require "google/cloud/storage"
1508
1539
  #
@@ -1542,28 +1573,85 @@ module Google
1542
1573
  # # Send the `x-goog-resumable:start` header and the content type
1543
1574
  # # with the resumable upload POST request.
1544
1575
  #
1545
- def signed_url method: nil, expires: nil, content_type: nil,
1546
- content_md5: nil, headers: nil, issuer: nil,
1547
- client_email: nil, signing_key: nil, private_key: nil,
1548
- query: nil, version: nil
1576
+ # @example Using Cloud IAMCredentials signBlob to create the signature:
1577
+ # require "google/cloud/storage"
1578
+ # require "google/apis/iamcredentials_v1"
1579
+ # require "googleauth"
1580
+ #
1581
+ # # Issuer is the service account email that the Signed URL will be signed with
1582
+ # # and any permission granted in the Signed URL must be granted to the
1583
+ # # Google Service Account.
1584
+ # issuer = "service-account@project-id.iam.gserviceaccount.com"
1585
+ #
1586
+ # # Create a lambda that accepts the string_to_sign
1587
+ # signer = lambda do |string_to_sign|
1588
+ # IAMCredentials = Google::Apis::IamcredentialsV1
1589
+ # iam_client = IAMCredentials::IAMCredentialsService.new
1590
+ #
1591
+ # # Get the environment configured authorization
1592
+ # scopes = ["https://www.googleapis.com/auth/iam"]
1593
+ # iam_client.authorization = Google::Auth.get_application_default scopes
1594
+ #
1595
+ # request = {
1596
+ # "payload": string_to_sign,
1597
+ # }
1598
+ # resource = "projects/-/serviceAccounts/#{issuer}"
1599
+ # response = iam_client.sign_service_account_blob resource, request, {}
1600
+ # response.signed_blob
1601
+ # end
1602
+ #
1603
+ # storage = Google::Cloud::Storage.new
1604
+ #
1605
+ # bucket = storage.bucket "my-todo-app"
1606
+ # file = bucket.file "avatars/heidi/400x400.png", skip_lookup: true
1607
+ # url = file.signed_url method: "GET", issuer: issuer,
1608
+ # signer: signer
1609
+ #
1610
+ def signed_url method: "GET",
1611
+ expires: nil,
1612
+ content_type: nil,
1613
+ content_md5: nil,
1614
+ headers: nil,
1615
+ issuer: nil,
1616
+ client_email: nil,
1617
+ signing_key: nil,
1618
+ private_key: nil,
1619
+ signer: nil,
1620
+ query: nil,
1621
+ scheme: "HTTPS",
1622
+ virtual_hosted_style: nil,
1623
+ bucket_bound_hostname: nil,
1624
+ version: nil
1549
1625
  ensure_service!
1550
1626
  version ||= :v2
1551
1627
  case version.to_sym
1552
1628
  when :v2
1553
- signer = File::SignerV2.from_file self
1554
- signer.signed_url method: method, expires: expires,
1555
- headers: headers, content_type: content_type,
1556
- content_md5: content_md5, issuer: issuer,
1557
- client_email: client_email,
1558
- signing_key: signing_key,
1559
- private_key: private_key, query: query
1629
+ sign = File::SignerV2.from_file self
1630
+ sign.signed_url method: method,
1631
+ expires: expires,
1632
+ headers: headers,
1633
+ content_type: content_type,
1634
+ content_md5: content_md5,
1635
+ issuer: issuer,
1636
+ client_email: client_email,
1637
+ signing_key: signing_key,
1638
+ private_key: private_key,
1639
+ signer: signer,
1640
+ query: query
1560
1641
  when :v4
1561
- signer = File::SignerV4.from_file self
1562
- signer.signed_url method: method, expires: expires,
1563
- headers: headers, issuer: issuer,
1564
- client_email: client_email,
1565
- signing_key: signing_key,
1566
- private_key: private_key, query: query
1642
+ sign = File::SignerV4.from_file self
1643
+ sign.signed_url method: method,
1644
+ expires: expires,
1645
+ headers: headers,
1646
+ issuer: issuer,
1647
+ client_email: client_email,
1648
+ signing_key: signing_key,
1649
+ private_key: private_key,
1650
+ signer: signer,
1651
+ query: query,
1652
+ scheme: scheme,
1653
+ virtual_hosted_style: virtual_hosted_style,
1654
+ bucket_bound_hostname: bucket_bound_hostname
1567
1655
  else
1568
1656
  raise ArgumentError, "version '#{version}' not supported"
1569
1657
  end
@@ -1732,7 +1820,7 @@ module Google
1732
1820
  # Sending nil metadata results in an Apiary runtime error:
1733
1821
  # NoMethodError: undefined method `each' for nil:NilClass
1734
1822
  attr_params.reject! { |k, v| k == :metadata && v.nil? }
1735
- Google::Apis::StorageV1::Object.new attr_params
1823
+ Google::Apis::StorageV1::Object.new(**attr_params)
1736
1824
  end
1737
1825
 
1738
1826
  protected
@@ -1783,12 +1871,12 @@ module Google
1783
1871
  user_project: user_project }.delete_if { |_k, v| v.nil? }
1784
1872
 
1785
1873
  resp = service.rewrite_file \
1786
- bucket, name, new_bucket, new_name, updated_gapi, options
1874
+ bucket, name, new_bucket, new_name, updated_gapi, **options
1787
1875
  until resp.done
1788
1876
  sleep 1
1789
1877
  retry_options = options.merge token: resp.rewrite_token
1790
1878
  resp = service.rewrite_file \
1791
- bucket, name, new_bucket, new_name, updated_gapi, retry_options
1879
+ bucket, name, new_bucket, new_name, updated_gapi, **retry_options
1792
1880
  end
1793
1881
  resp.resource
1794
1882
  end
@@ -77,11 +77,13 @@ module Google
77
77
  def next
78
78
  return nil unless next?
79
79
  ensure_service!
80
- options = {
81
- prefix: @prefix, delimiter: @delimiter, token: @token, max: @max,
82
- versions: @versions, user_project: @user_project
83
- }
84
- gapi = @service.list_files @bucket, options
80
+
81
+ gapi = @service.list_files @bucket, prefix: @prefix,
82
+ delimiter: @delimiter,
83
+ token: @token,
84
+ max: @max,
85
+ versions: @versions,
86
+ user_project: @user_project
85
87
  File::List.from_gapi gapi, @service, @bucket, @prefix,
86
88
  @delimiter, @max, @versions,
87
89
  user_project: @user_project
@@ -77,13 +77,21 @@ module Google
77
77
  end
78
78
 
79
79
  def determine_signing_key options = {}
80
- options[:signing_key] || options[:private_key] ||
81
- @service.credentials.signing_key
80
+ signing_key = options[:signing_key] || options[:private_key] ||
81
+ options[:signer] || @service.credentials.signing_key
82
+ raise SignedUrlUnavailable, error_msg("signing_key (private_key, signer)") unless signing_key
83
+ signing_key
82
84
  end
83
85
 
84
86
  def determine_issuer options = {}
85
- options[:issuer] || options[:client_email] ||
86
- @service.credentials.issuer
87
+ issuer = options[:issuer] || options[:client_email] || @service.credentials.issuer
88
+ raise SignedUrlUnavailable, error_msg("issuer (client_email)") unless issuer
89
+ issuer
90
+ end
91
+
92
+ def error_msg attr_name
93
+ "Service account credentials '#{attr_name}' is missing. To generate service account credentials " \
94
+ "see https://cloud.google.com/iam/docs/service-accounts"
87
95
  end
88
96
 
89
97
  def post_object options
@@ -99,8 +107,6 @@ module Google
99
107
  i = determine_issuer options
100
108
  s = determine_signing_key options
101
109
 
102
- raise SignedUrlUnavailable unless i && s
103
-
104
110
  policy_str = p.to_json
105
111
  policy = Base64.strict_encode64(policy_str).delete "\n"
106
112
 
@@ -119,18 +125,21 @@ module Google
119
125
  i = determine_issuer options
120
126
  s = determine_signing_key options
121
127
 
122
- raise SignedUrlUnavailable unless i && s
123
-
124
128
  sig = generate_signature s, signature_str(options)
125
129
  generate_signed_url i, sig, options[:expires], options[:query]
126
130
  end
127
131
 
128
132
  def generate_signature signing_key, secret
129
- unless signing_key.respond_to? :sign
130
- signing_key = OpenSSL::PKey::RSA.new signing_key
133
+ unencoded_signature = ""
134
+ if signing_key.is_a? Proc
135
+ unencoded_signature = signing_key.call secret
136
+ else
137
+ unless signing_key.respond_to? :sign
138
+ signing_key = OpenSSL::PKey::RSA.new signing_key
139
+ end
140
+ unencoded_signature = signing_key.sign OpenSSL::Digest::SHA256.new, secret
131
141
  end
132
- signature = signing_key.sign OpenSSL::Digest::SHA256.new, secret
133
- Base64.strict_encode64(signature).delete "\n"
142
+ Base64.strict_encode64(unencoded_signature).delete "\n"
134
143
  end
135
144
 
136
145
  def generate_signed_url issuer, signed_string, expires, query
@@ -39,37 +39,83 @@ module Google
39
39
  new bucket.name, file_name, bucket.service
40
40
  end
41
41
 
42
- def signed_url method: "GET", expires: nil, headers: nil,
43
- issuer: nil, client_email: nil, signing_key: nil,
44
- private_key: nil, query: nil
45
- issuer, signer = issuer_and_signer issuer, client_email,
46
- signing_key, private_key
42
+ def post_object issuer: nil,
43
+ client_email: nil,
44
+ signing_key: nil,
45
+ private_key: nil,
46
+ signer: nil,
47
+ expires: nil,
48
+ fields: nil,
49
+ conditions: nil,
50
+ scheme: "https",
51
+ virtual_hosted_style: nil,
52
+ bucket_bound_hostname: nil
53
+ i = determine_issuer issuer, client_email
54
+ s = determine_signing_key signing_key, private_key, signer
55
+
56
+ now = Time.now.utc
57
+ base_fields = required_fields i, now
58
+ post_fields = fields.dup || {}
59
+ post_fields.merge! base_fields
60
+
61
+ p = {}
62
+ p["conditions"] = policy_conditions base_fields, conditions, fields
63
+ expires ||= 60*60*24
64
+ p["expiration"] = (now + expires).strftime "%Y-%m-%dT%H:%M:%SZ"
65
+
66
+ policy_str = escape_characters p.to_json
67
+
68
+ policy = Base64.strict_encode64(policy_str).force_encoding "utf-8"
69
+ signature = generate_signature s, policy
70
+
71
+ post_fields["x-goog-signature"] = signature
72
+ post_fields["policy"] = policy
73
+ url = post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
74
+ hostname = "#{url}#{bucket_path path_style?(virtual_hosted_style, bucket_bound_hostname)}"
75
+ Google::Cloud::Storage::PostObject.new hostname, post_fields
76
+ end
77
+
78
+ def signed_url method: "GET",
79
+ expires: nil,
80
+ headers: nil,
81
+ issuer: nil,
82
+ client_email: nil,
83
+ signing_key: nil,
84
+ private_key: nil,
85
+ signer: nil,
86
+ query: nil,
87
+ scheme: "https",
88
+ virtual_hosted_style: nil,
89
+ bucket_bound_hostname: nil
90
+ raise ArgumentError, "method is required" unless method
91
+ issuer, signer = issuer_and_signer issuer, client_email, signing_key, private_key, signer
47
92
  datetime_now = Time.now.utc
48
93
  goog_date = datetime_now.strftime "%Y%m%dT%H%M%SZ"
49
94
  datestamp = datetime_now.strftime "%Y%m%d"
50
95
  # goog4_request is not checked.
51
96
  scope = "#{datestamp}/auto/storage/goog4_request"
52
97
 
53
- canonical_headers_str, signed_headers_str = \
54
- canonical_and_signed_headers headers
98
+ canonical_headers_str, signed_headers_str = canonical_and_signed_headers \
99
+ headers, virtual_hosted_style, bucket_bound_hostname
55
100
 
56
101
  algorithm = "GOOG4-RSA-SHA256"
57
102
  expires = determine_expires expires
58
- credential = CGI.escape issuer + "/" + scope
59
- canonical_query_str = canonical_query query, algorithm,
60
- credential, goog_date,
61
- expires, signed_headers_str
103
+ credential = issuer + "/" + scope
104
+ canonical_query_str = canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str
62
105
 
63
106
  # From AWS: You don't include a payload hash in the Canonical
64
107
  # Request, because when you create a presigned URL, you don't know
65
108
  # the payload content because the URL is used to upload an arbitrary
66
109
  # payload. Instead, you use a constant string UNSIGNED-PAYLOAD.
110
+ payload = headers&.key?("X-Goog-Content-SHA256") ? headers["X-Goog-Content-SHA256"] : "UNSIGNED-PAYLOAD"
111
+
67
112
  canonical_request = [method,
68
- ext_path,
113
+ file_path(!(virtual_hosted_style || bucket_bound_hostname)),
69
114
  canonical_query_str,
70
115
  canonical_headers_str,
71
116
  signed_headers_str,
72
- "UNSIGNED-PAYLOAD"].join("\n")
117
+ payload].join("\n")
118
+
73
119
  # Construct string to sign
74
120
  req_sha = Digest::SHA256.hexdigest canonical_request
75
121
  string_to_sign = [algorithm, goog_date, scope, req_sha].join "\n"
@@ -78,66 +124,129 @@ module Google
78
124
  signature = signer.call string_to_sign
79
125
 
80
126
  # Construct signed URL
81
- "#{ext_url}?#{canonical_query_str}&X-Goog-Signature=#{signature}"
127
+ hostname = signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
128
+ "#{hostname}?#{canonical_query_str}&X-Goog-Signature=#{signature}"
129
+ end
130
+
131
+ # methods below are public visibility only for unit testing
132
+ def escape_characters str
133
+ str.split("").map do |s|
134
+ if !s.ascii_only?
135
+ escape_special_unicode s
136
+ else
137
+ case s
138
+ when "\\"
139
+ '\\'
140
+ when "\b"
141
+ '\b'
142
+ when "\f"
143
+ '\f'
144
+ when "\n"
145
+ '\n'
146
+ when "\r"
147
+ '\r'
148
+ when "\t"
149
+ '\t'
150
+ when "\v"
151
+ '\v'
152
+ else
153
+ s
154
+ end
155
+ end
156
+ end.join
157
+ end
158
+
159
+ def escape_special_unicode str
160
+ str.unpack("U*").map { |i| '\u' + i.to_s(16).rjust(4, "0") }.join
82
161
  end
83
162
 
84
163
  protected
85
164
 
165
+ def required_fields issuer, time
166
+ {
167
+ "key" => @file_name,
168
+ "x-goog-date" => time.strftime("%Y%m%dT%H%M%SZ"),
169
+ "x-goog-credential" => "#{issuer}/#{time.strftime '%Y%m%d'}/auto/storage/goog4_request",
170
+ "x-goog-algorithm" => "GOOG4-RSA-SHA256"
171
+ }.freeze
172
+ end
173
+
174
+ def policy_conditions base_fields, user_conditions, user_fields
175
+ # Convert each pair in base_fields hash to a single-entry hash in an array.
176
+ conditions = base_fields.to_a.map { |f| Hash[*f] }
177
+ # Add the bucket to the head of the base_fields. This is not returned in the PostObject fields.
178
+ conditions.unshift "bucket" => @bucket_name
179
+ # Add user-provided conditions to the head of the conditions array.
180
+ conditions.unshift user_conditions if user_conditions && !user_conditions.empty?
181
+ if user_fields
182
+ # Convert each pair in fields hash to a single-entry hash and add it to the head of the conditions array.
183
+ user_fields.to_a.reverse.each { |f| conditions.unshift Hash[*f] }
184
+ end
185
+ conditions.freeze
186
+ end
187
+
188
+ def signed_url_hostname scheme, virtual_hosted_style, bucket_bound_hostname
189
+ url = ext_url scheme, virtual_hosted_style, bucket_bound_hostname
190
+ "#{url}#{file_path path_style?(virtual_hosted_style, bucket_bound_hostname)}"
191
+ end
192
+
86
193
  def determine_issuer issuer, client_email
87
194
  # Parse the Service Account and get client id and private key
88
195
  issuer = issuer || client_email || @service.credentials.issuer
89
- unless issuer
90
- raise SignedUrlUnavailable, "issuer (client_email) missing"
91
- end
196
+ raise SignedUrlUnavailable, error_msg("issuer (client_email)") unless issuer
92
197
  issuer
93
198
  end
94
199
 
95
- def determine_signing_key signing_key, private_key
96
- signing_key = signing_key || private_key ||
97
- @service.credentials.signing_key
98
- unless signing_key
99
- raise SignedUrlUnavailable, "signing_key (private_key) missing"
100
- end
200
+ def determine_signing_key signing_key, private_key, signer
201
+ signing_key = signing_key || private_key || signer || @service.credentials.signing_key
202
+ raise SignedUrlUnavailable, error_msg("signing_key (private_key, signer)") unless signing_key
101
203
  signing_key
102
204
  end
103
205
 
206
+ def error_msg attr_name
207
+ "Service account credentials '#{attr_name}' is missing. To generate service account credentials " \
208
+ "see https://cloud.google.com/iam/docs/service-accounts"
209
+ end
210
+
104
211
  def service_account_signer signer
105
- unless signer.respond_to? :sign
106
- signer = OpenSSL::PKey::RSA.new signer
107
- end
108
- # Sign string to sign
109
- lambda do |string_to_sign|
110
- sig = signer.sign OpenSSL::Digest::SHA256.new, string_to_sign
111
- sig.unpack("H*").first
212
+ if signer.is_a? Proc
213
+ lambda do |string_to_sign|
214
+ sig = signer.call string_to_sign
215
+ sig.unpack("H*").first
216
+ end
217
+ else
218
+ signer = OpenSSL::PKey::RSA.new signer unless signer.respond_to? :sign
219
+ # Sign string to sign
220
+ lambda do |string_to_sign|
221
+ sig = signer.sign OpenSSL::Digest::SHA256.new, string_to_sign
222
+ sig.unpack("H*").first
223
+ end
112
224
  end
113
225
  end
114
226
 
115
- def issuer_and_signer issuer, client_email, signing_key, private_key
227
+ def issuer_and_signer issuer, client_email, signing_key, private_key, signer
116
228
  issuer = determine_issuer issuer, client_email
117
- signing_key = determine_signing_key signing_key, private_key
229
+ signing_key = determine_signing_key signing_key, private_key, signer
118
230
  signer = service_account_signer signing_key
119
231
  [issuer, signer]
120
232
  end
121
233
 
122
- def canonical_and_signed_headers headers
123
- # Headers needs to be in alpha order.
234
+ def canonical_and_signed_headers headers, virtual_hosted_style, bucket_bound_hostname
235
+ if virtual_hosted_style && bucket_bound_hostname
236
+ raise "virtual_hosted_style: #{virtual_hosted_style} and bucket_bound_hostname: " \
237
+ "#{bucket_bound_hostname} params cannot both be passed together"
238
+ end
239
+
124
240
  canonical_headers = headers || {}
125
241
  headers_arr = canonical_headers.map do |k, v|
126
- [k.downcase, v.strip.gsub(/[^\S\t]+/, " ").gsub(/\t+/, "\t")]
242
+ [k.downcase, v.strip.gsub(/[^\S\t]+/, " ").gsub(/\t+/, " ")]
127
243
  end
128
244
  canonical_headers = Hash[headers_arr]
129
- canonical_headers["host"] = "storage.googleapis.com"
130
-
131
- canonical_headers = canonical_headers.sort_by do |k, _|
132
- k.downcase
133
- end.to_h
134
- canonical_headers_str = ""
135
- canonical_headers.each do |k, v|
136
- canonical_headers_str += "#{k}:#{v}\n"
137
- end
138
- signed_headers_str = ""
139
- canonical_headers.each_key { |k| signed_headers_str += "#{k};" }
140
- signed_headers_str = signed_headers_str.chomp ";"
245
+ canonical_headers["host"] = host_name virtual_hosted_style, bucket_bound_hostname
246
+
247
+ canonical_headers = canonical_headers.sort_by(&:first).to_h
248
+ canonical_headers_str = canonical_headers.map { |k, v| "#{k}:#{v}\n" }.join
249
+ signed_headers_str = canonical_headers.keys.join ";"
141
250
  [canonical_headers_str, signed_headers_str]
142
251
  end
143
252
 
@@ -149,32 +258,108 @@ module Google
149
258
  expires
150
259
  end
151
260
 
152
- def canonical_query query, algorithm, credential, goog_date, expires,
153
- signed_headers_str
261
+ def canonical_query query, algorithm, credential, goog_date, expires, signed_headers_str
154
262
  query ||= {}
155
263
  query["X-Goog-Algorithm"] = algorithm
156
264
  query["X-Goog-Credential"] = credential
157
265
  query["X-Goog-Date"] = goog_date
158
266
  query["X-Goog-Expires"] = expires
159
- query["X-Goog-SignedHeaders"] = CGI.escape signed_headers_str
160
- query = query.sort_by { |k, _| k.to_s.downcase }.to_h
161
- canonical_query_str = ""
162
- query.each { |k, v| canonical_query_str += "#{k}=#{v}&" }
163
- canonical_query_str.chomp "&"
267
+ query["X-Goog-SignedHeaders"] = signed_headers_str
268
+ query = query.map { |k, v| [escape_query_param(k), escape_query_param(v)] }.sort_by(&:first).to_h
269
+ query.map { |k, v| "#{k}=#{v}" }.join "&"
270
+ end
271
+
272
+ ##
273
+ # Only the characters in the regex set [A-Za-z0-9.~_-] must be left un-escaped; all others must be
274
+ # percent-encoded using %XX UTF-8 style.
275
+ def escape_query_param str
276
+ CGI.escape(str.to_s).gsub("%7E", "~")
277
+ end
278
+
279
+ def host_name virtual_hosted_style, bucket_bound_hostname
280
+ return bucket_bound_hostname if bucket_bound_hostname
281
+ virtual_hosted_style ? "#{@bucket_name}.storage.googleapis.com" : "storage.googleapis.com"
164
282
  end
165
283
 
166
284
  ##
167
285
  # The URI-encoded (percent encoded) external path to the file.
168
- def ext_path
169
- path = "/#{@bucket_name}"
170
- path += "/#{String(@file_name)}" if @file_name && !@file_name.empty?
171
- Addressable::URI.escape path
286
+ def file_path path_style
287
+ path = []
288
+ path << "/#{@bucket_name}" if path_style
289
+ path << "/#{String(@file_name)}" if @file_name && !@file_name.empty?
290
+ CGI.escape(path.join).gsub "%2F", "/"
291
+ end
292
+
293
+ ##
294
+ # The external path to the bucket, with trailing slash.
295
+ def bucket_path path_style
296
+ return "/#{@bucket_name}/" if path_style
297
+ end
298
+
299
+ ##
300
+ # The external url to the file.
301
+ def ext_url scheme, virtual_hosted_style, bucket_bound_hostname
302
+ url = GOOGLEAPIS_URL.dup
303
+ if virtual_hosted_style
304
+ parts = url.split "//"
305
+ parts[1] = "#{@bucket_name}.#{parts[1]}"
306
+ parts.join "//"
307
+ elsif bucket_bound_hostname
308
+ raise ArgumentError, "scheme is required" unless scheme
309
+ URI "#{scheme.to_s.downcase}://#{bucket_bound_hostname}"
310
+ else
311
+ url
312
+ end
313
+ end
314
+
315
+ def path_style? virtual_hosted_style, bucket_bound_hostname
316
+ !(virtual_hosted_style || bucket_bound_hostname)
317
+ end
318
+
319
+ ##
320
+ # The external path to the file, URI-encoded.
321
+ # Will not URI encode the special `${filename}` variable.
322
+ # "You can also use the ${filename} variable..."
323
+ # https://cloud.google.com/storage/docs/xml-api/post-object
324
+ #
325
+ def post_object_ext_path
326
+ path = "/#{@bucket_name}/#{@file_name}"
327
+ escaped = Addressable::URI.escape path
328
+ special_var = "${filename}"
329
+ # Restore the unencoded `${filename}` variable, if present.
330
+ if path.include? special_var
331
+ return escaped.gsub "$%7Bfilename%7D", special_var
332
+ end
333
+ escaped
172
334
  end
173
335
 
174
336
  ##
175
337
  # The external url to the file.
176
- def ext_url
177
- "#{GOOGLEAPIS_URL}#{ext_path}"
338
+ def post_object_ext_url scheme, virtual_hosted_style, bucket_bound_hostname
339
+ url = GOOGLEAPIS_URL.dup
340
+ if virtual_hosted_style
341
+ parts = url.split "//"
342
+ parts[1] = "#{@bucket_name}.#{parts[1]}/"
343
+ parts.join "//"
344
+ elsif bucket_bound_hostname
345
+ raise ArgumentError, "scheme is required" unless scheme
346
+ URI "#{scheme.to_s.downcase}://#{bucket_bound_hostname}/"
347
+ else
348
+ url
349
+ end
350
+ end
351
+
352
+ def generate_signature signing_key, data
353
+ packed_signature = nil
354
+ if signing_key.is_a? Proc
355
+ packed_signature = signing_key.call data
356
+ else
357
+ unless signing_key.respond_to? :sign
358
+ signing_key = OpenSSL::PKey::RSA.new signing_key
359
+ end
360
+ packed_signature = signing_key.sign OpenSSL::Digest::SHA256.new, data
361
+ end
362
+ packed_signature.unpack("H*").first.force_encoding "utf-8"
178
363
  end
179
364
  end
180
365
  end