google-cloud-storage 1.25.1 → 1.28.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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