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 +4 -4
- data/.github/workflows/ci.yml +25 -0
- data/CHANGELOG.md +31 -0
- data/Gemfile +3 -2
- data/README.md +17 -1
- data/faster_s3_url.gemspec +2 -1
- data/lib/faster_s3_url/builder.rb +72 -53
- data/lib/faster_s3_url/builder_with_new_escape.rb +296 -0
- data/lib/faster_s3_url/shrine/storage.rb +3 -1
- data/lib/faster_s3_url/version.rb +1 -1
- metadata +21 -5
- data/.travis.yml +0 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 21d5f75ad6e25913b8540b317745ccff1dacf631685963083a5fe498bb04195f
|
4
|
+
data.tar.gz: 888224050710da2bf891268a0654c0bd15e89fde0d6f7d58aae2e2f3d0a3d2ca
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
11
|
-
|
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
|
-
[](https://badge.fury.io/rb/faster_s3_url) [](https://badge.fury.io/rb/faster_s3_url) [](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
|
|
data/faster_s3_url.gemspec
CHANGED
@@ -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.
|
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
|
-
|
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
|
-
"
|
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
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
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 "
|
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
|
-
#
|
211
|
-
#
|
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.
|
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
|
-
#
|
226
|
-
#
|
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.
|
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
|
-
#
|
269
|
-
# an
|
270
|
-
#
|
271
|
-
#
|
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
|
-
#
|
275
|
-
#
|
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.
|
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
|
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.
|
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:
|
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
|
-
-
|
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.
|
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.
|
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
|