faster_s3_url 1.1.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: 19961406eae206e2857fd3f33d6a9f2d17a2f87974accd449db44f00a5d086fd
4
- data.tar.gz: 8e490830bd078b73eddfc00eee1f0cc847432bab536cee1a4e5eb2635666cc4a
3
+ metadata.gz: 21d5f75ad6e25913b8540b317745ccff1dacf631685963083a5fe498bb04195f
4
+ data.tar.gz: 888224050710da2bf891268a0654c0bd15e89fde0d6f7d58aae2e2f3d0a3d2ca
5
5
  SHA512:
6
- metadata.gz: 40b65520c01ae860dea80dba4a25c7817d9a8978c1cbc7bc538d7b0ed92e5c898597340f0c93141355929af98bb7aa7656c95469c758348744ddd77b6666b9ec
7
- data.tar.gz: 12d30342cb72e806e6c11e9c0e27b078cd0e1dfa419303437724d76acb698b30ec1b500bde020dd513802d65097ddc7f2997b9e9cfb59bd669f5e6bb085e9fb9
6
+ metadata.gz: 65bd748773211c8cd720502fd008de4dba4f1a08d81495987f702ae7e7a2a128288cb15d40df149b0166e4d7a0ec6fd94bf8aa198398b834cd8fb423829be38e
7
+ data.tar.gz: 31bb93d34192d73b787d36ff87c0223b0de430fa1fe5c712d3aa6a65d67c6bfbcccf6a23bbc45139a8e871889900ae4515d38c24028f288ff4dbdc68556aadcf
@@ -16,7 +16,7 @@ jobs:
16
16
  - name: Set up Ruby
17
17
  uses: ruby/setup-ruby@v1
18
18
  with:
19
- ruby-version: 2.6.6
19
+ ruby-version: 3.2.4
20
20
 
21
21
  - name: Bundle install
22
22
  run: bundle install --jobs 4 --retry 3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
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
+
8
21
  ## 1.1.0
9
22
 
10
23
  ### Fixed
data/README.md CHANGED
@@ -75,6 +75,20 @@ builder = FasterS3Url::Builder.new(
75
75
  builder.presign_url(key) # performance enhanced
76
76
  ```
77
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
+ ```
78
92
 
79
93
  ### Automatic AWS credentials lookup?
80
94
 
@@ -90,6 +104,7 @@ credentials = credentials.credentials if credentials.respond_to?(:credentials)
90
104
 
91
105
  access_key_id = credentials.access_key_id
92
106
  secret_access_key = credentials.secret_access_key
107
+ session_token = credentials.session_token # only needed when using AWS Security Token Service
93
108
  region = client.config.region
94
109
  ```
95
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
 
@@ -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
@@ -206,42 +207,51 @@ module FasterS3Url
206
207
  end
207
208
  end
208
209
 
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.
210
+
211
+ # CGI.escapeURIComponent has correct semantics for what AWS wants, and is
212
+ # implemented in C, so pretty fast.
213
213
  def uri_escape(string)
214
214
  if string.nil?
215
215
  nil
216
216
  else
217
- CGI.escape(string.encode('UTF-8')).tap do |s|
218
- # there is a clever way to do this in one gsub, but doesn't necessarily help
219
- # memory allocations or performance
220
- s.gsub!('+'.freeze, '%20'.freeze)
221
- s.gsub!('%7E'.freeze, '~'.freeze)
222
- end
217
+ CGI.escapeURIComponent(string.encode('UTF-8'))
223
218
  end
224
219
  end
225
220
 
226
221
  # like uri_escape but does NOT escape `/`, leaves it alone. The appropriate
227
222
  # escaping algorithm for an S3 key turning into a URL.
228
223
  #
229
- # Faster to un-DRY the code with uri_escape. Yes, faster to actually just gsub
230
- # %2F back to /
224
+ # Using CGI.escapeURIComponent with a gsub is faster than anything else
225
+ # we found to get this semantics.
231
226
  def uri_escape_key(string)
232
227
  if string.nil?
233
228
  nil
234
229
  else
235
- CGI.escape(string.encode('UTF-8')).tap do |s|
236
- # there is a clever way to do this in one gsub, but doesn't necessarily help
237
- # memory allocations or performance
238
- s.gsub!('+'.freeze, '%20'.freeze)
239
- s.gsub!('%7E'.freeze, '~'.freeze)
230
+ CGI.escapeURIComponent(string.encode('UTF-8')).tap do |s|
240
231
  s.gsub!('%2F'.freeze, '/'.freeze)
241
232
  end
242
233
  end
243
234
  end
244
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}"
251
+ end
252
+ parsed
253
+ end
254
+
245
255
  def default_host(bucket_name)
246
256
  if region == "us-east-1"
247
257
  # use legacy one without region, as S3 seems to
@@ -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,3 @@
1
1
  module FasterS3Url
2
- VERSION = "1.1.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.1.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: 2023-12-04 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
@@ -115,6 +115,7 @@ files:
115
115
  - faster_s3_url.gemspec
116
116
  - lib/faster_s3_url.rb
117
117
  - lib/faster_s3_url/builder.rb
118
+ - lib/faster_s3_url/builder_with_new_escape.rb
118
119
  - lib/faster_s3_url/shrine/storage.rb
119
120
  - lib/faster_s3_url/version.rb
120
121
  - perf/presigned_bench.rb
@@ -133,14 +134,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
133
134
  requirements:
134
135
  - - ">="
135
136
  - !ruby/object:Gem::Version
136
- version: 2.3.0
137
+ version: 3.2.0
137
138
  required_rubygems_version: !ruby/object:Gem::Requirement
138
139
  requirements:
139
140
  - - ">="
140
141
  - !ruby/object:Gem::Version
141
142
  version: '0'
142
143
  requirements: []
143
- rubygems_version: 3.4.21
144
+ rubygems_version: 3.5.9
144
145
  signing_key:
145
146
  specification_version: 4
146
147
  summary: Generate public and presigned AWS S3 GET URLs faster