faster_s3_url 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f3c703ea5ea25203b0d37da4ba0272c9619d7d46bbe0eed9bd7a3fd79768473
4
- data.tar.gz: a2060cdee17b8731274a4ea997859df1390cf09f4dae2530d2145e3345ffabaf
3
+ metadata.gz: 21d5f75ad6e25913b8540b317745ccff1dacf631685963083a5fe498bb04195f
4
+ data.tar.gz: 888224050710da2bf891268a0654c0bd15e89fde0d6f7d58aae2e2f3d0a3d2ca
5
5
  SHA512:
6
- metadata.gz: e8867a60091b046eb1956589e14666b10bee32711bbadfed2fcfab9e25dde2459e60baf0d61fa2564e73d838a55803ffef11a5e21452d9352b994d0af9749efe
7
- data.tar.gz: bb29010a02d2e89773ecf43a8b1c041f81d855f0bd80cbf3251fa00bd5eeb557b73ae51fb53a12a421296276786beed38246a6920b96639628e3dcb8b433c42c
6
+ metadata.gz: 65bd748773211c8cd720502fd008de4dba4f1a08d81495987f702ae7e7a2a128288cb15d40df149b0166e4d7a0ec6fd94bf8aa198398b834cd8fb423829be38e
7
+ data.tar.gz: 31bb93d34192d73b787d36ff87c0223b0de430fa1fe5c712d3aa6a65d67c6bfbcccf6a23bbc45139a8e871889900ae4515d38c24028f288ff4dbdc68556aadcf
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: ['**']
8
+
9
+ jobs:
10
+ tests:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v2
15
+
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: 3.2.4
20
+
21
+ - name: Bundle install
22
+ run: bundle install --jobs 4 --retry 3
23
+
24
+ - name: Run tests
25
+ run: bundle exec rake
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## 1.2.0
9
+
10
+ ### Changed
11
+
12
+ - Now requires at least ruby 3.1
13
+ - uses CGI.escapeURIComponent for somewhat improved performance https://github.com/jrochkind/faster_s3_url/pull/8
14
+
15
+ ### Added
16
+
17
+ - Add `session_token` option to `Builder` from [@BenKanouse](https://github.com/BenKanouse) https://github.com/jrochkind/faster_s3_url/pull/12
18
+
19
+ - Add 'endpoint' option to Builder, thanks @BenKanouse, https://github.com/jrochkind/faster_s3_url/pull/14
20
+
21
+ ## 1.1.0
22
+
23
+ ### Fixed
24
+
25
+ - response-expires header format match recent AWS ruby SDK by using #httpdate https://github.com/jrochkind/faster_s3_url/pull/5
26
+
27
+ - Only define local Storage#object_key if Shrine isn't already providing https://github.com/jrochkind/faster_s3_url/pull/7
28
+
29
+ ### Changed
30
+
31
+ - Some decrease in memory allocations made by gem https://github.com/jrochkind/faster_s3_url/pull/6
data/Gemfile CHANGED
@@ -7,5 +7,6 @@ gem "rake", "~> 12.0"
7
7
  gem "rspec", "~> 3.0"
8
8
 
9
9
  gem 'pry-byebug', "~> 3.9"
10
- # need straight from github to get latest version without deprecations, eg https://github.com/softdevteam/libkalibera/issues/5
11
- gem 'kalibera', github: "softdevteam/libkalibera"
10
+ # used by benchmark-ips in our perf/ profiling scripts.
11
+ # https://github.com/evanphx/benchmark-ips#advanced-statistics
12
+ gem 'kalibera'
data/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Generate public and presigned AWS S3 `GET` URLs faster in ruby
4
4
 
5
- [![Gem Version](https://badge.fury.io/rb/faster_s3_url.svg)](https://badge.fury.io/rb/faster_s3_url) [![Build Status](https://travis-ci.com/jrochkind/faster_s3_url.svg?branch=master)](https://travis-ci.com/jrochkind/faster_s3_url)
5
+ [![Gem Version](https://badge.fury.io/rb/faster_s3_url.svg)](https://badge.fury.io/rb/faster_s3_url) [![CI Status](https://github.com/jrochkind/faster_s3_url/workflows/CI/badge.svg?branch=master)](https://github.com/jrochkind/faster_s3_url/actions?query=workflow%3ACI+branch%3Amaster)
6
+
6
7
 
7
8
  The official [ruby AWS SDK](https://github.com/aws/aws-sdk-ruby) is actually quite slow and unoptimized when generating URLs to access S3 objects. If you are only creating a couple S3 URLs at a time this may not matter. But it can matter on the order of even two or three hundred at a time, especially when creating presigned URLs, for which the AWS SDK is especially un-optimized.
8
9
 
@@ -74,6 +75,20 @@ builder = FasterS3Url::Builder.new(
74
75
  builder.presign_url(key) # performance enhanced
75
76
  ```
76
77
 
78
+ ### Using AWS Security Token Service (AWS STS)?
79
+
80
+ When using AWS Security Token Service (AWS STS), AWS requires that the X-Amz-Security-Token parameter is set on presigned URLs. To accomplish this, you will need to pass the optional `session_token` parameter into the `FasterS3Url::Builder`.
81
+
82
+ ```ruby
83
+ builder = FasterS3Url::Builder.new(
84
+ bucket_name: "my-bucket.example.com",
85
+ region: "us-east-1",
86
+ access_key_id: ENV['AWS_ACCESS_KEY'],
87
+ secret_access_key: ENV['AWS_SECRET_KEY'],
88
+ session_token: "session_token"
89
+ )
90
+ builder.presign_url(key) # includes required X-Amz-Security-Token
91
+ ```
77
92
 
78
93
  ### Automatic AWS credentials lookup?
79
94
 
@@ -89,6 +104,7 @@ credentials = credentials.credentials if credentials.respond_to?(:credentials)
89
104
 
90
105
  access_key_id = credentials.access_key_id
91
106
  secret_access_key = credentials.secret_access_key
107
+ session_token = credentials.session_token # only needed when using AWS Security Token Service
92
108
  region = client.config.region
93
109
  ```
94
110
 
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.summary = %q{Generate public and presigned AWS S3 GET URLs faster}
10
10
  spec.homepage = "https://github.com/jrochkind/faster_s3_url"
11
11
  spec.license = "MIT"
12
- spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
13
13
 
14
14
  #spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
15
15
 
@@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ["lib"]
28
28
 
29
29
  spec.add_development_dependency "aws-sdk-s3", "~> 1.81"
30
+ spec.add_development_dependency "nokogiri" # aws gem now has implicit dependency on an XML processor such as
30
31
  spec.add_development_dependency "timecop", "< 2"
31
32
  spec.add_development_dependency "benchmark-ips", "~> 2.8"
32
33
  #spec.add_development_dependency "kalibera" # for benchmark-ips :bootstrap stats option
@@ -20,7 +20,8 @@ module FasterS3Url
20
20
 
21
21
  MAX_CACHED_SIGNING_KEYS = 5
22
22
 
23
- attr_reader :bucket_name, :region, :host, :access_key_id
23
+ attr_reader :bucket_name, :region, :host, :access_key_id, :session_token
24
+ private attr_reader :base_url, :base_path
24
25
 
25
26
  # @option params [String] :bucket_name required
26
27
  #
@@ -28,6 +29,8 @@ module FasterS3Url
28
29
  #
29
30
  # @option params[String] :host optional, host to use in generated URLs. If empty, will construct default AWS S3 host for bucket name and region.
30
31
  #
32
+ # @option params[String] :endpoint optional. `endpoint` as in AWS SDK S3, to point at non-standard AWS locations. Mutually exclusive with `host`, can be used to point to alternate systems including local S3 clones like minio.
33
+ #
31
34
  # @option params [String] :access_key_id required at present, change to allow look up from environment using standard aws sdk routines?
32
35
  #
33
36
  # @option params [String] :secret_access_key required at present, change to allow look up from environment using standard aws sdk routines?
@@ -38,24 +41,32 @@ module FasterS3Url
38
41
  # be cached and re-used, improving performance when generating mulitple presigned urls with a single Builder by around 50%.
39
42
  # NOTE WELL: This will make the Builder no longer technically concurrency-safe for sharing between multiple threads, is one
40
43
  # reason it is not on by default.
41
- def initialize(bucket_name:, region:, access_key_id:, secret_access_key:, host:nil, default_public: true, cache_signing_keys: false)
44
+ def initialize(bucket_name:, region:, access_key_id:, secret_access_key:, session_token:nil, host:nil, endpoint: nil, default_public: true, cache_signing_keys: false)
45
+ if endpoint && host
46
+ raise ArgumentError.new("`endpoint` and `host` are mutually exclusive, you can only provide one. You provided endpoint: #{endpoint.inspect} and host: #{host.inspect}")
47
+ end
48
+
42
49
  @bucket_name = bucket_name
43
50
  @region = region
44
- @host = host || default_host(bucket_name)
51
+
52
+ parsed_uri = parsed_base_uri(bucket_name: bucket_name, host: host, endpoint: endpoint)
53
+ @base_url = URI.join(parsed_uri, "/").to_s.chomp("/") # without path
54
+ @base_path = parsed_uri.path # path component of base url, usually empty
55
+ @host = parsed_uri.host
56
+ @canonical_headers = "host:#{parsed_uri.port == parsed_uri.default_port ? @host : "#{parsed_uri.host}:#{parsed_uri.port}"}\n"
57
+
45
58
  @default_public = default_public
46
59
  @access_key_id = access_key_id
47
60
  @secret_access_key = secret_access_key
48
61
  @cache_signing_keys = cache_signing_keys
62
+ @session_token = session_token
49
63
  if @cache_signing_keys
50
64
  @signing_key_cache = {}
51
65
  end
52
-
53
-
54
- @canonical_headers = "host:#{@host}\n"
55
66
  end
56
67
 
57
68
  def public_url(key)
58
- "https://#{self.host}/#{uri_escape_key(key)}"
69
+ "#{self.base_url}#{self.base_path}/#{uri_escape_key(key)}"
59
70
  end
60
71
 
61
72
  # Generates a presigned GET URL for a specified S3 object key.
@@ -95,7 +106,7 @@ module FasterS3Url
95
106
  version_id: nil)
96
107
  validate_expires_in(expires_in)
97
108
 
98
- canonical_uri = "/" + uri_escape_key(key)
109
+ canonical_uri = self.base_path + "/" + uri_escape_key(key)
99
110
 
100
111
  now = time ? time.dup.utc : Time.now.utc # Uh Time#utc is mutating, not nice to do to an argument!
101
112
  amz_date = now.strftime("%Y%m%dT%H%M%SZ")
@@ -103,35 +114,25 @@ module FasterS3Url
103
114
 
104
115
  credential_scope = datestamp + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
105
116
 
106
- canonical_query_string_parts = [
107
- "X-Amz-Algorithm=#{ALGORITHM}",
108
- "X-Amz-Credential=" + uri_escape(@access_key_id + "/" + credential_scope),
109
- "X-Amz-Date=" + amz_date,
110
- "X-Amz-Expires=" + expires_in.to_s,
111
- "X-Amz-SignedHeaders=" + SIGNED_HEADERS,
112
- ]
113
-
114
- extra_params = {
115
- :"response-cache-control" => response_cache_control,
116
- :"response-content-disposition" => response_content_disposition,
117
- :"response-content-encoding" => response_content_encoding,
118
- :"response-content-language" => response_content_language,
119
- :"response-content-type" => response_content_type,
120
- :"response-expires" => convert_for_timestamp_shape(response_expires),
121
- :"versionId" => version_id
117
+ # These have to be sorted, but sort is case-sensitive, and we have a fixed
118
+ # list of headers we know might be here... turns out they are already sorted?
119
+ canonical_query_params = {
120
+ "X-Amz-Algorithm": ALGORITHM,
121
+ "X-Amz-Credential": uri_escape(@access_key_id + "/" + credential_scope),
122
+ "X-Amz-Date": amz_date,
123
+ "X-Amz-Expires": expires_in.to_s,
124
+ "X-Amz-Security-Token": uri_escape(session_token),
125
+ "X-Amz-SignedHeaders": SIGNED_HEADERS,
126
+ "response-cache-control": uri_escape(response_cache_control),
127
+ "response-content-disposition": uri_escape(response_content_disposition),
128
+ "response-content-encoding": uri_escape(response_content_encoding),
129
+ "response-content-language": uri_escape(response_content_language),
130
+ "response-content-type": uri_escape(response_content_type),
131
+ "response-expires": uri_escape(convert_for_timestamp_shape(response_expires)),
132
+ "versionId": uri_escape(version_id)
122
133
  }.compact
123
134
 
124
-
125
- if extra_params.size > 0
126
- # These have to be sorted, but sort is case-sensitive, and we have a fixed
127
- # list of headers we know might be here... turns out they are already sorted?
128
- extra_param_parts = extra_params.collect {|k, v| "#{k}=#{uri_escape v}" }.join("&")
129
- canonical_query_string_parts << extra_param_parts
130
- end
131
-
132
- canonical_query_string = canonical_query_string_parts.join("&")
133
-
134
-
135
+ canonical_query_string = canonical_query_params.collect {|k, v| "#{k}=#{v}" }.join("&")
135
136
 
136
137
  canonical_request = ["GET",
137
138
  canonical_uri,
@@ -151,7 +152,7 @@ module FasterS3Url
151
152
  signing_key = retrieve_signing_key(datestamp)
152
153
  signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
153
154
 
154
- return "https://" + self.host + canonical_uri + "?" + canonical_query_string + "&X-Amz-Signature=" + signature
155
+ return "#{base_url}#{canonical_uri}?#{canonical_query_string}&X-Amz-Signature=#{signature}"
155
156
  end
156
157
 
157
158
  # just a convenience method that can call public_url or presigned_url based on flag
@@ -207,29 +208,48 @@ module FasterS3Url
207
208
  end
208
209
 
209
210
 
210
- # Becaues CGI.escape in MRI is written in C, this really does seem
211
- # to be the fastest way to get the semantics we want, starting with
212
- # CGI.escape and doing extra gsubs. Alternative would be using something
213
- # else in pure C that has the semantics we want, but does not seem available.
211
+ # CGI.escapeURIComponent has correct semantics for what AWS wants, and is
212
+ # implemented in C, so pretty fast.
214
213
  def uri_escape(string)
215
214
  if string.nil?
216
215
  nil
217
216
  else
218
- CGI.escape(string.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~')
217
+ CGI.escapeURIComponent(string.encode('UTF-8'))
219
218
  end
220
219
  end
221
220
 
222
221
  # like uri_escape but does NOT escape `/`, leaves it alone. The appropriate
223
222
  # escaping algorithm for an S3 key turning into a URL.
224
223
  #
225
- # Faster to un-DRY the code with uri_escape. Yes, faster to actually just gsub
226
- # %2F back to /
224
+ # Using CGI.escapeURIComponent with a gsub is faster than anything else
225
+ # we found to get this semantics.
227
226
  def uri_escape_key(string)
228
227
  if string.nil?
229
228
  nil
230
229
  else
231
- CGI.escape(string.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~').gsub("%2F", "/")
230
+ CGI.escapeURIComponent(string.encode('UTF-8')).tap do |s|
231
+ s.gsub!('%2F'.freeze, '/'.freeze)
232
+ end
233
+ end
234
+ end
235
+
236
+ # Handle endpoint, modifying host or path with bucketname, and setting
237
+ # host.
238
+ #
239
+ # if none set, set default host.
240
+ #
241
+ # Set base_url correct for host or endpoint.
242
+ def parsed_base_uri(bucket_name:, host:, endpoint:)
243
+ return URI.parse("https://#{host}") if host
244
+ return URI.parse("https://#{default_host(bucket_name)}") if endpoint.nil?
245
+
246
+ parsed = URI.parse(endpoint)
247
+ if parsed.host =~ /\A\d+\.\d+\.\d+\.\d+\Z/
248
+ parsed.path = "/#{bucket_name}"
249
+ else
250
+ parsed.host = "#{bucket_name}.#{parsed.host}"
232
251
  end
252
+ parsed
233
253
  end
234
254
 
235
255
  def default_host(bucket_name)
@@ -256,7 +276,7 @@ module FasterS3Url
256
276
 
257
277
  def validate_expires_in(expires_in)
258
278
  if expires_in > ONE_WEEK
259
- raise ArgumentError.new("expires_in value of #{expires_in} exceeds one-week maximum.")
279
+ raise ArgumentError.new("expires_in value of #{expires_in} exceeds one-week (#{ONE_WEEK}) maximum.")
260
280
  elsif expires_in <= 0
261
281
  raise ArgumentError.new("expires_in value of #{expires_in} cannot be 0 or less.")
262
282
  end
@@ -265,14 +285,13 @@ module FasterS3Url
265
285
  # Crazy kind of reverse engineered from aws-sdk-ruby,
266
286
  # for compatible handling of Expires header.
267
287
  #
268
- # This honestly seems to violate the HTTP spec, the result will be that for
269
- # an `response-expires` param, subsequent S3 response will include an Expires
270
- # header in ISO8601 instead of HTTP-date format.... but for now we'll make
271
- # our tests pass by behaving equivalently to aws-sdk-s3 anyway? filed
272
- # with aws-sdk-s3: https://github.com/aws/aws-sdk-ruby/issues/2415
288
+ # Recent versions of ruby AWS SDK use "httpdate" format here, as a result of
289
+ # an issue we filed: https://github.com/aws/aws-sdk-ruby/issues/2415
290
+ #
291
+ # We match what recent AWS SDK does.
273
292
  #
274
- # Switch last line from `.utc.iso8601` to `.httpdate` if you want to be
275
- # more correct than aws-sdk-s3?
293
+ # Note while the AWS SDK source says "rfc 822", it's ruby #httpdate that matches
294
+ # rather than ruby #rfc822 (timezone should be `GMT` to match AWS SDK, not `-0000`)
276
295
  def convert_for_timestamp_shape(arg)
277
296
  return nil if arg.nil?
278
297
 
@@ -286,7 +305,7 @@ module FasterS3Url
286
305
  else
287
306
  Time.parse(arg.to_s)
288
307
  end
289
- time_value.utc.iso8601
308
+ time_value.utc.httpdate
290
309
  end
291
310
  end
292
311
  end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module FasterS3Url
6
+ # Signing algorithm based on Amazon docs at https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html ,
7
+ # as well as some interactive code reading of Aws::Sigv4::Signer
8
+ # https://github.com/aws/aws-sdk-ruby/blob/6114bc9692039ac75c8292c66472dacd14fa6f9a/gems/aws-sigv4/lib/aws-sigv4/signer.rb
9
+ # as used by Aws::S3::Presigner https://github.com/aws/aws-sdk-ruby/blob/6114bc9692039ac75c8292c66472dacd14fa6f9a/gems/aws-sdk-s3/lib/aws-sdk-s3/presigner.rb
10
+ class BuilderWithNewEscape
11
+ FIFTEEN_MINUTES = 60 * 15
12
+ ONE_WEEK = 60 * 60 * 24 * 7
13
+
14
+ SIGNED_HEADERS = "host".freeze
15
+ METHOD = "GET".freeze
16
+ ALGORITHM = "AWS4-HMAC-SHA256".freeze
17
+ SERVICE = "s3".freeze
18
+
19
+ DEFAULT_EXPIRES_IN = FIFTEEN_MINUTES # 15 minutes, seems to be AWS SDK default
20
+
21
+ MAX_CACHED_SIGNING_KEYS = 5
22
+
23
+ attr_reader :bucket_name, :region, :host, :access_key_id
24
+
25
+ # @option params [String] :bucket_name required
26
+ #
27
+ # @option params [String] :region eg "us-east-1", required
28
+ #
29
+ # @option params[String] :host optional, host to use in generated URLs. If empty, will construct default AWS S3 host for bucket name and region.
30
+ #
31
+ # @option params [String] :access_key_id required at present, change to allow look up from environment using standard aws sdk routines?
32
+ #
33
+ # @option params [String] :secret_access_key required at present, change to allow look up from environment using standard aws sdk routines?
34
+ #
35
+ # @option params [boolean] :default_public (true) default value of `public` when instance method #url is called.
36
+ #
37
+ # @option params [boolean] :cache_signing_keys (false). If set to true, up to five signing keys used for presigned URLs will
38
+ # be cached and re-used, improving performance when generating mulitple presigned urls with a single Builder by around 50%.
39
+ # NOTE WELL: This will make the Builder no longer technically concurrency-safe for sharing between multiple threads, is one
40
+ # reason it is not on by default.
41
+ def initialize(bucket_name:, region:, access_key_id:, secret_access_key:, host:nil, default_public: true, cache_signing_keys: false)
42
+ @bucket_name = bucket_name
43
+ @region = region
44
+ @host = host || default_host(bucket_name)
45
+ @default_public = default_public
46
+ @access_key_id = access_key_id
47
+ @secret_access_key = secret_access_key
48
+ @cache_signing_keys = cache_signing_keys
49
+ if @cache_signing_keys
50
+ @signing_key_cache = {}
51
+ end
52
+
53
+
54
+ @canonical_headers = "host:#{@host}\n"
55
+ end
56
+
57
+ def public_url(key)
58
+ "https://#{self.host}/#{uri_escape_key(key)}"
59
+ end
60
+
61
+ # Generates a presigned GET URL for a specified S3 object key.
62
+ #
63
+ # @param [String] key The S3 key to create a URL pointing to.
64
+ #
65
+ # @option params [Time] :time (Time.now) The starting time for when the
66
+ # presigned url becomes active.
67
+ #
68
+ # @option params [String] :response_cache_control
69
+ # Adds a `response-cache-control` query param to set the `Cache-Control` header of the subsequent response from S3.
70
+ #
71
+ # @option params [String] :response_content_disposition
72
+ # Adds a `response-content-disposition` query param to set the `Content-Disposition` header of the subsequent response from S3
73
+ #
74
+ # @option params [String] :response_content_encoding
75
+ # Adds a `response-content-encoding` query param to set `Content-Encoding` header of the subsequent response from S3
76
+ #
77
+ # @option params [String] :response_content_language
78
+ # Adds a `response-content-language` query param to sets the `Content-Language` header of the subsequent response from S3
79
+ #
80
+ # @option params [String] :response_content_type
81
+ # Adds a `response-content-type` query param to sets the `Content-Type` header of the subsequent response from S3
82
+ #
83
+ # @option params [String] :response_expires
84
+ # Adds a `response-expires` query param to sets the `Expires` header of of the subsequent response from S3
85
+ #
86
+ # @option params [String] :version_id
87
+ # Adds a `versionId` query param to reference a specific version of the object from S3.
88
+ def presigned_url(key, time: nil, expires_in: DEFAULT_EXPIRES_IN,
89
+ response_cache_control: nil,
90
+ response_content_disposition: nil,
91
+ response_content_encoding: nil,
92
+ response_content_language: nil,
93
+ response_content_type: nil,
94
+ response_expires: nil,
95
+ version_id: nil)
96
+ validate_expires_in(expires_in)
97
+
98
+ canonical_uri = "/" + uri_escape_key(key)
99
+
100
+ now = time ? time.dup.utc : Time.now.utc # Uh Time#utc is mutating, not nice to do to an argument!
101
+ amz_date = now.strftime("%Y%m%dT%H%M%SZ")
102
+ datestamp = now.strftime("%Y%m%d")
103
+
104
+ credential_scope = datestamp + '/' + region + '/' + SERVICE + '/' + 'aws4_request'
105
+
106
+ canonical_query_string_parts = [
107
+ "X-Amz-Algorithm=#{ALGORITHM}",
108
+ "X-Amz-Credential=" + uri_escape(@access_key_id + "/" + credential_scope),
109
+ "X-Amz-Date=" + amz_date,
110
+ "X-Amz-Expires=" + expires_in.to_s,
111
+ "X-Amz-SignedHeaders=" + SIGNED_HEADERS,
112
+ ]
113
+
114
+ extra_params = {
115
+ :"response-cache-control" => response_cache_control,
116
+ :"response-content-disposition" => response_content_disposition,
117
+ :"response-content-encoding" => response_content_encoding,
118
+ :"response-content-language" => response_content_language,
119
+ :"response-content-type" => response_content_type,
120
+ :"response-expires" => convert_for_timestamp_shape(response_expires),
121
+ :"versionId" => version_id
122
+ }.compact
123
+
124
+
125
+ if extra_params.size > 0
126
+ # These have to be sorted, but sort is case-sensitive, and we have a fixed
127
+ # list of headers we know might be here... turns out they are already sorted?
128
+ extra_param_parts = extra_params.collect {|k, v| "#{k}=#{uri_escape v}" }.join("&")
129
+ canonical_query_string_parts << extra_param_parts
130
+ end
131
+
132
+ canonical_query_string = canonical_query_string_parts.join("&")
133
+
134
+
135
+
136
+ canonical_request = ["GET",
137
+ canonical_uri,
138
+ canonical_query_string,
139
+ @canonical_headers,
140
+ SIGNED_HEADERS,
141
+ 'UNSIGNED-PAYLOAD'
142
+ ].join("\n")
143
+
144
+ string_to_sign = [
145
+ ALGORITHM,
146
+ amz_date,
147
+ credential_scope,
148
+ Digest::SHA256.hexdigest(canonical_request)
149
+ ].join("\n")
150
+
151
+ signing_key = retrieve_signing_key(datestamp)
152
+ signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
153
+
154
+ return "https://" + self.host + canonical_uri + "?" + canonical_query_string + "&X-Amz-Signature=" + signature
155
+ end
156
+
157
+ # just a convenience method that can call public_url or presigned_url based on flag
158
+ #
159
+ # signer.url(object_key, public: true)
160
+ # #=> forwards to signer.public_url(object_key)
161
+ #
162
+ # signer.url(object_key, public: false, response_content_type: "image/jpeg")
163
+ # #=> forwards to signer.presigned_url(object_key, response_content_type: "image/jpeg")
164
+ #
165
+ # Options (sucn as response_content_type) that are not applicable to #public_url
166
+ # are ignored in public mode.
167
+ #
168
+ # The default value of `public` can be set by initializer arg `default_public`, which
169
+ # is itself default true.
170
+ #
171
+ # builder = FasterS3Url::Builder.new(..., default_public: false)
172
+ # builder.url(object_key) # will call #presigned_url
173
+ def url(key, public: @default_public, **options)
174
+ if public
175
+ public_url(key)
176
+ else
177
+ presigned_url(key, **options)
178
+ end
179
+ end
180
+
181
+
182
+ private
183
+
184
+ def make_signing_key(datestamp)
185
+ aws_get_signature_key(@secret_access_key, datestamp, @region, SERVICE)
186
+ end
187
+
188
+ # If caching of signing keys is turned on, use and cache signing key, while
189
+ # making sure not to cache more than MAX_CACHED_SIGNING_KEYS
190
+ #
191
+ # Otherwise if caching of signing keys is not turned on, just generate and return
192
+ # a signing key.
193
+ def retrieve_signing_key(datestamp)
194
+ if @cache_signing_keys
195
+ if value = @signing_key_cache[datestamp]
196
+ value
197
+ else
198
+ value = @signing_key_cache[datestamp] = make_signing_key(datestamp)
199
+ while @signing_key_cache.size > MAX_CACHED_SIGNING_KEYS
200
+ @signing_key_cache.delete(@signing_key_cache.keys.first)
201
+ end
202
+ value
203
+ end
204
+ else
205
+ make_signing_key(datestamp)
206
+ end
207
+ end
208
+
209
+ # Becaues CGI.escape in MRI is written in C, this really does seem
210
+ # to be the fastest way to get the semantics we want, starting with
211
+ # CGI.escape and doing extra gsubs. Alternative would be using something
212
+ # else in pure C that has the semantics we want, but does not seem available.
213
+ def uri_escape(string)
214
+ if string.nil?
215
+ nil
216
+ else
217
+ CGI.escapeURIComponent(string.encode('UTF-8'))
218
+ end
219
+ end
220
+
221
+ # like uri_escape but does NOT escape `/`, leaves it alone. The appropriate
222
+ # escaping algorithm for an S3 key turning into a URL.
223
+ #
224
+ # Faster to un-DRY the code with uri_escape. Yes, faster to actually just gsub
225
+ # %2F back to /
226
+ def uri_escape_key(string)
227
+ if string.nil?
228
+ nil
229
+ else
230
+ CGI.escapeURIComponent(string.encode('UTF-8')).tap do |s|
231
+ # there is a clever way to do this in one gsub, but doesn't necessarily help
232
+ # memory allocations or performance
233
+ #s.gsub!('+'.freeze, '%20'.freeze)
234
+ #s.gsub!('%7E'.freeze, '~'.freeze)
235
+ s.gsub!('%2F'.freeze, '/'.freeze)
236
+ end
237
+ end
238
+ end
239
+
240
+ def default_host(bucket_name)
241
+ if region == "us-east-1"
242
+ # use legacy one without region, as S3 seems to
243
+ "#{bucket_name}.s3.amazonaws.com".freeze
244
+ else
245
+ "#{bucket_name}.s3.#{region}.amazonaws.com".freeze
246
+ end
247
+ end
248
+
249
+ # `def get_signature_key` `from python example at https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
250
+ def aws_get_signature_key(key, date_stamp, region_name, service_name)
251
+ k_date = aws_sign("AWS4" + key, date_stamp)
252
+ k_region = aws_sign(k_date, region_name)
253
+ k_service = aws_sign(k_region, service_name)
254
+ aws_sign(k_service, "aws4_request")
255
+ end
256
+
257
+ # `def sign` from python example at https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html
258
+ def aws_sign(key, data)
259
+ OpenSSL::HMAC.digest("SHA256", key, data)
260
+ end
261
+
262
+ def validate_expires_in(expires_in)
263
+ if expires_in > ONE_WEEK
264
+ raise ArgumentError.new("expires_in value of #{expires_in} exceeds one-week (#{ONE_WEEK}) maximum.")
265
+ elsif expires_in <= 0
266
+ raise ArgumentError.new("expires_in value of #{expires_in} cannot be 0 or less.")
267
+ end
268
+ end
269
+
270
+ # Crazy kind of reverse engineered from aws-sdk-ruby,
271
+ # for compatible handling of Expires header.
272
+ #
273
+ # Recent versions of ruby AWS SDK use "httpdate" format here, as a result of
274
+ # an issue we filed: https://github.com/aws/aws-sdk-ruby/issues/2415
275
+ #
276
+ # We match what recent AWS SDK does.
277
+ #
278
+ # Note while the AWS SDK source says "rfc 822", it's ruby #httpdate that matches
279
+ # rather than ruby #rfc822 (timezone should be `GMT` to match AWS SDK, not `-0000`)
280
+ def convert_for_timestamp_shape(arg)
281
+ return nil if arg.nil?
282
+
283
+ time_value = case arg
284
+ when Time
285
+ arg
286
+ when Date, DateTime
287
+ arg.to_time
288
+ when Integer, Float
289
+ Time.at(arg)
290
+ else
291
+ Time.parse(arg.to_s)
292
+ end
293
+ time_value.utc.httpdate
294
+ end
295
+ end
296
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  gem "shrine", "~> 3.0"
2
4
  require 'shrine/storage/s3'
3
5
 
@@ -59,7 +61,7 @@ module FasterS3Url
59
61
  end
60
62
 
61
63
  # For older shrine versions without it, we need this...
62
- unless self.method_defined?(:object_key)
64
+ unless self.method_defined?(:object_key) || self.private_method_defined?(:object_key)
63
65
  def object_key(id)
64
66
  [*prefix, id].join("/")
65
67
  end
@@ -1,3 +1,3 @@
1
1
  module FasterS3Url
2
- VERSION = "1.0.0"
2
+ VERSION = "1.2.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faster_s3_url
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Rochkind
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-10-17 00:00:00.000000000 Z
11
+ date: 2024-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-s3
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.81'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: timecop
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -87,9 +101,10 @@ executables: []
87
101
  extensions: []
88
102
  extra_rdoc_files: []
89
103
  files:
104
+ - ".github/workflows/ci.yml"
90
105
  - ".gitignore"
91
106
  - ".rspec"
92
- - ".travis.yml"
107
+ - CHANGELOG.md
93
108
  - Gemfile
94
109
  - LICENSE.txt
95
110
  - README.md
@@ -100,6 +115,7 @@ files:
100
115
  - faster_s3_url.gemspec
101
116
  - lib/faster_s3_url.rb
102
117
  - lib/faster_s3_url/builder.rb
118
+ - lib/faster_s3_url/builder_with_new_escape.rb
103
119
  - lib/faster_s3_url/shrine/storage.rb
104
120
  - lib/faster_s3_url/version.rb
105
121
  - perf/presigned_bench.rb
@@ -118,14 +134,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
118
134
  requirements:
119
135
  - - ">="
120
136
  - !ruby/object:Gem::Version
121
- version: 2.3.0
137
+ version: 3.2.0
122
138
  required_rubygems_version: !ruby/object:Gem::Requirement
123
139
  requirements:
124
140
  - - ">="
125
141
  - !ruby/object:Gem::Version
126
142
  version: '0'
127
143
  requirements: []
128
- rubygems_version: 3.0.3
144
+ rubygems_version: 3.5.9
129
145
  signing_key:
130
146
  specification_version: 4
131
147
  summary: Generate public and presigned AWS S3 GET URLs faster
data/.travis.yml DELETED
@@ -1,6 +0,0 @@
1
- ---
2
- language: ruby
3
- cache: bundler
4
- rvm:
5
- - 2.6.6
6
- before_install: gem install bundler -v 2.1.4