s3-light 1.0.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.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ class ConcurrentResult
5
+ def initialize
6
+ @hash = Concurrent::Hash.new
7
+ end
8
+
9
+ def add(key, value)
10
+ @hash[key] = value
11
+ end
12
+
13
+ def to_h
14
+ @hash
15
+ end
16
+
17
+ def inspect
18
+ "#<#{self.class.name} @hash=#{@hash}>"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ class Configuration
5
+ attr_accessor :io_buffer_size
6
+
7
+ def initialize
8
+ @io_buffer_size = 16 * (1024 * 1024)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ class Connection
5
+ class Body
6
+ def initialize(body)
7
+ @body = body
8
+ validate_body_type!
9
+ @size = calculate_size
10
+ @io = to_io
11
+ end
12
+
13
+ attr_reader :size
14
+
15
+ def read(length = nil, outbuf = nil)
16
+ @io.read(length, outbuf)
17
+ end
18
+
19
+ def rewind
20
+ @io.rewind
21
+ end
22
+
23
+ private
24
+ def to_io
25
+ case @body
26
+ when String
27
+ StringIO.new(@body)
28
+ when StringIO, IO
29
+ @body
30
+ when Enumerable
31
+ StringIO.new(@body.to_a.join)
32
+ when nil
33
+ StringIO.new('')
34
+ else
35
+ raise RequestError, "body of wrong type: #{@body.class}"
36
+ end
37
+ end
38
+
39
+ def calculate_size
40
+ case @body
41
+ when String
42
+ @body.bytesize
43
+ when StringIO, IO
44
+ @body.size
45
+ when Enumerable
46
+ @body.to_a.join.bytesize
47
+ when nil
48
+ 0
49
+ else
50
+ raise RequestError, 'cannot determine size of body'
51
+ end
52
+ end
53
+
54
+ def validate_body_type!
55
+ return if @body.is_a?(String)
56
+ return if @body.respond_to?(:read)
57
+ return if @body.is_a?(Enumerable)
58
+ return if @body.nil?
59
+
60
+ raise RequestError, "body of wrong type: #{@body.class}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ class Connection
5
+ class Response
6
+ def initialize(http_response)
7
+ @http_response = http_response
8
+ end
9
+
10
+ def xml
11
+ return @xml if defined? @xml
12
+
13
+ @xml = Nokogiri::XML(@http_response.body.to_s)
14
+ end
15
+
16
+ def code
17
+ @http_response.code
18
+ end
19
+
20
+ def body
21
+ @http_response.body
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ class Connection
5
+ class HttpError < StandardError
6
+ attr_reader :response
7
+
8
+ def initialize(response)
9
+ @response = response
10
+ super("HTTP Error: #{response.code} - #{response.body}")
11
+ end
12
+
13
+ def code
14
+ @response.code
15
+ end
16
+ end
17
+ attr_reader :endpoint
18
+
19
+ def initialize(endpoint:, access_key_id:, secret_access_key:, ssl_context:)
20
+ @endpoint = URI(endpoint)
21
+ @access_key_id = access_key_id
22
+ @secret_access_key = secret_access_key
23
+ @ssl_context = ssl_context
24
+ @opened = false
25
+ end
26
+
27
+ def persistent_connection
28
+ return @persistent_connection if @persistent_connection
29
+
30
+ @persistent_connection = HTTP.persistent(@endpoint).headers(
31
+ 'User-Agent' => "S3Light/#{S3Light::VERSION}",
32
+ 'Host' => @endpoint.host
33
+ )
34
+ ObjectSpace.define_finalizer(self, self.class.close_connection(@persistent_connection))
35
+
36
+ @persistent_connection
37
+ end
38
+
39
+ def make_request(method, path, headers: {}, body: nil)
40
+ @opened = true
41
+ full_path = URI.join(@endpoint, path).to_s
42
+ request_time = Time.now.utc
43
+ body = body.is_a?(Body) ? body : Body.new(body)
44
+
45
+ headers = build_headers(method, full_path, headers, body, request_time)
46
+
47
+ response = stream_request(method, full_path, headers, body)
48
+
49
+ handle_response(response)
50
+ end
51
+
52
+ def download_file(path, output_path)
53
+ @opened = true
54
+ full_path = URI.join(@endpoint, path).to_s
55
+ request_time = Time.now.utc
56
+
57
+ headers = build_headers('GET', full_path, {}, Body.new(nil), request_time)
58
+
59
+ File.open(output_path, 'wb') do |file|
60
+ response = persistent_connection.headers(headers).get(full_path, ssl_context: @ssl_context)
61
+ raise HttpError.new(response) if response.code > 399
62
+
63
+ response.body.each do |chunk|
64
+ file.write(chunk)
65
+ end
66
+ end
67
+
68
+ output_path
69
+ end
70
+
71
+ def close
72
+ @persistent_connection&.close
73
+ @opened = false
74
+ end
75
+
76
+ def ==(other_connection)
77
+ @endpoint == other_connection.endpoint
78
+ end
79
+
80
+ def inspect
81
+ "#<#{self.class.name} @endpoint=#{@endpoint} @opened=#{@opened}>"
82
+ end
83
+
84
+ def self.close_connection(connection)
85
+ proc do
86
+ connection.close
87
+ end
88
+ end
89
+
90
+ private
91
+ def stream_request(method, full_path, headers, body)
92
+ if body.size > 0
93
+ headers['Content-Length'] = body.size.to_s
94
+ end
95
+
96
+ persistent_connection.headers(headers).request(
97
+ method,
98
+ full_path,
99
+ ssl_context: @ssl_context,
100
+ body: body
101
+ )
102
+ end
103
+
104
+ def build_headers(method, path, headers, body, request_time)
105
+ canonical_headers = {
106
+ 'host' => @endpoint.host,
107
+ 'x-amz-date' => request_time.strftime('%Y%m%dT%H%M%SZ'),
108
+ 'x-amz-content-sha256' => 'UNSIGNED-PAYLOAD'
109
+ }
110
+
111
+ canonical_headers['content-length'] = body.size.to_s if body.size > 0
112
+
113
+ signed_headers = canonical_headers.keys.sort.join(';')
114
+
115
+ auth_header = authorization_header(
116
+ method,
117
+ path,
118
+ canonical_headers,
119
+ signed_headers,
120
+ body,
121
+ request_time
122
+ )
123
+
124
+ headers.merge(canonical_headers).merge('Authorization' => auth_header)
125
+ end
126
+
127
+ def authorization_header(method, path, canonical_headers, signed_headers, body, request_time)
128
+ algorithm = 'AWS4-HMAC-SHA256'
129
+ credential_scope = credential_scope(request_time)
130
+ string_to_sign = string_to_sign(method, path, canonical_headers, signed_headers, body, request_time)
131
+ signature = signature(string_to_sign, request_time)
132
+
133
+ "#{algorithm} Credential=#{@access_key_id}/#{credential_scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
134
+ end
135
+
136
+ def credential_scope(request_time)
137
+ date = request_time.strftime('%Y%m%d')
138
+ region = @endpoint.host.split('.')[1] || 'us-east-1'
139
+ "#{date}/#{region}/s3/aws4_request"
140
+ end
141
+
142
+ def string_to_sign(method, path, canonical_headers, signed_headers, body, request_time)
143
+ uri = URI(path)
144
+ canonical_request = [
145
+ method.to_s.upcase,
146
+ uri.path,
147
+ uri.query || '',
148
+ canonical_headers.sort.map { |k, v| "#{k}:#{v.strip}" }.join("\n") + "\n",
149
+ signed_headers,
150
+ 'UNSIGNED-PAYLOAD'
151
+ ].join("\n")
152
+
153
+ [
154
+ 'AWS4-HMAC-SHA256',
155
+ request_time.strftime('%Y%m%dT%H%M%SZ'),
156
+ credential_scope(request_time),
157
+ Digest::SHA256.hexdigest(canonical_request)
158
+ ].join("\n")
159
+ end
160
+
161
+ def signature(string_to_sign, request_time)
162
+ date = request_time.strftime('%Y%m%d')
163
+ region = @endpoint.host.split('.')[1] || 'us-east-1'
164
+ service = 's3'
165
+
166
+ k_date = hmac("AWS4#{@secret_access_key}", date)
167
+ k_region = hmac(k_date, region)
168
+ k_service = hmac(k_region, service)
169
+ k_signing = hmac(k_service, 'aws4_request')
170
+
171
+ OpenSSL::HMAC.hexdigest('sha256', k_signing, string_to_sign)
172
+ end
173
+
174
+ def hmac(key, value)
175
+ OpenSSL::HMAC.digest('sha256', key, value)
176
+ end
177
+
178
+ def handle_response(response)
179
+ case response.code
180
+ when 200..299
181
+ Connection::Response.new(response)
182
+ else
183
+ raise HttpError.new(response)
184
+ end
185
+ end
186
+
187
+ def chunked?(headers)
188
+ headers['Transfer-Encoding'] == 'chunked'
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ module ConnectionsManager
5
+ class Main
6
+ def initialize
7
+ @connections = {}
8
+ end
9
+
10
+ def yield_connection(client, &block)
11
+ client_connection = nil
12
+ @connections.each do |existing_client, connection|
13
+ if client.endpoint == existing_client.endpoint
14
+ client_connection = connection
15
+ break
16
+ end
17
+ end
18
+
19
+ client_connection ||= create_new_connection(client)
20
+ @connections[client] = client_connection
21
+
22
+ block.call(client_connection)
23
+ end
24
+
25
+ def close_all_connections
26
+ return if @connections.empty?
27
+ @connections.each do |_, connection|
28
+ connection.close
29
+ end
30
+
31
+ @connections = {}
32
+ end
33
+
34
+ private
35
+ def create_new_connection(client)
36
+ Connection.new(
37
+ endpoint: client.endpoint, access_key_id: client.access_key_id, secret_access_key: client.secret_access_key,
38
+ ssl_context: client.ssl_context
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ module ConnectionsManager
5
+ class Threaded
6
+ def initialize(client, concurrency)
7
+ @client = client
8
+ @thread_pool = Concurrent::FixedThreadPool.new(concurrency)
9
+ @connections = Concurrent::Array.new
10
+ end
11
+
12
+ def with_connection
13
+ @thread_pool.post do
14
+ existing_connection = Thread.current[:connection]
15
+ yield existing_connection if existing_connection
16
+
17
+ new_connection = create_new_connection
18
+ @connections << new_connection
19
+ Thread.current[:connection] = new_connection
20
+
21
+ yield new_connection
22
+ end
23
+ end
24
+
25
+ def close
26
+ @connections.each(&:close)
27
+ @connections = Concurrent::Array.new
28
+ end
29
+
30
+ def wait_to_finish
31
+ @thread_pool.shutdown
32
+ @thread_pool.wait_for_termination
33
+ end
34
+
35
+ private
36
+ def create_new_connection
37
+ Connection.new(
38
+ endpoint: @client.endpoint, access_key_id: @client.access_key_id, secret_access_key: @client.secret_access_key,
39
+ ssl_context: @client.ssl_context
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ class Md5Calculator
5
+ def initialize(input)
6
+ @input = input
7
+ end
8
+
9
+ def md5
10
+ @md5 ||=
11
+ case @input
12
+ when String
13
+ Digest::MD5.hexdigest(@input)
14
+ when IO, StringIO
15
+ compute_md5_for_io
16
+ when Pathname
17
+ compute_md5_for_file_path
18
+ else
19
+ raise ArgumentError, "Unsupported input type: #{@input.class}"
20
+ end
21
+ end
22
+
23
+ def inspect
24
+ "#<#{self.class.name}>"
25
+ end
26
+
27
+ private
28
+ def compute_md5_for_io
29
+ Digest::MD5.new.tap do |md5|
30
+ io_buffer_size = S3Light.configuration.io_buffer_size
31
+ while chunk = @input.read(io_buffer_size)
32
+ md5 << chunk
33
+ end
34
+ @input.rewind
35
+ end.hexdigest
36
+ end
37
+
38
+ def compute_md5_for_file_path
39
+ Digest::MD5.new.tap do |md5|
40
+ io_buffer_size = S3Light.configuration.io_buffer_size
41
+ File.open(@input, 'rb') do |file|
42
+ while chunk = file.read(io_buffer_size)
43
+ md5 << chunk
44
+ end
45
+ end
46
+ end.hexdigest
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ Object = Struct.new(:client, :bucket, :key, :input, :persisted) do
5
+ def initialize(client, bucket, id, input, persisted = false)
6
+ super(client, bucket, id, input, persisted)
7
+ end
8
+
9
+ def persisted?
10
+ persisted
11
+ end
12
+
13
+ def inspect
14
+ "#<#{self.class.name} @id=#{key} @bucket=#{bucket.name}>"
15
+ end
16
+
17
+ def save!
18
+ raise S3Light::Error, 'Input is empty' if input.nil?
19
+
20
+ self.client.with_connection do |connection|
21
+ __save!(connection)
22
+ end
23
+ self.persisted = true
24
+
25
+ self
26
+ end
27
+
28
+ def destroy!
29
+ unless persisted
30
+ raise S3Light::Error, 'Object does not exist'
31
+ end
32
+
33
+ self.client.with_connection do |connection|
34
+ __destroy!(connection)
35
+ rescue S3Light::Connection::HttpError => e
36
+ raise e unless e.code == 404
37
+ end
38
+
39
+ self.persisted = false
40
+ true
41
+ end
42
+
43
+ def acl
44
+ return @acl if defined?(@acl)
45
+
46
+ response = client.with_connection do |connection|
47
+ connection.make_request(:get, "/#{bucket.name}/#{key}?acl=1")
48
+ end
49
+
50
+ @acl = response.xml.remove_namespaces!.xpath('//AccessControlList/Grant').each_with_object({}) do |grant, result|
51
+ permission = grant.at_xpath('Permission').text
52
+ grantee = grant.at_xpath('Grantee')
53
+
54
+ grantee_type = grantee['type']
55
+
56
+ identifier =
57
+ case grantee_type
58
+ when 'CanonicalUser'
59
+ grantee.at_xpath('ID')&.text || 'CanonicalUser'
60
+ when 'Group'
61
+ grantee.at_xpath('URI')&.text
62
+ else
63
+ grantee.at_xpath('EmailAddress')&.text
64
+ end
65
+
66
+ result[identifier] = permission if identifier
67
+ end
68
+ end
69
+
70
+
71
+ def acl=(new_acl)
72
+ xml_body = build_acl_xml(new_acl)
73
+
74
+ client.with_connection do |connection|
75
+ connection.make_request(:put, "/#{bucket.name}/#{key}?acl=1", body: xml_body)
76
+ end
77
+
78
+ @acl = new_acl
79
+ end
80
+
81
+ def download(to: nil)
82
+ raise S3Light::Error, 'Object does not exist' unless persisted
83
+
84
+ to ||= "/tmp/#{SecureRandom.hex}-#{key}"
85
+
86
+ self.client.with_connection do |connection|
87
+ __download(to, connection)
88
+ end
89
+ end
90
+
91
+ def open(mode = 'r', &block)
92
+ raise S3Light::Error, 'Object does not exist' unless persisted
93
+ download_path = download
94
+ file = File.open(download_path, mode)
95
+ yield file
96
+ self.input = file
97
+ file
98
+ end
99
+
100
+ def __save!(connection)
101
+ connection.make_request(:put, "/#{bucket.name}/#{key}", body: input)
102
+ end
103
+
104
+ def __destroy!(connection)
105
+ connection.make_request(:delete, "/#{bucket.name}/#{key}")
106
+ end
107
+
108
+ def __download(to, connection)
109
+ connection.download_file("/#{bucket.name}/#{key}", to)
110
+ end
111
+
112
+ private
113
+ def build_acl_xml(acl)
114
+ builder = Nokogiri::XML::Builder.new do |xml|
115
+ xml.AccessControlPolicy {
116
+ xml.AccessControlList {
117
+ acl.each do |grantee, permission|
118
+ xml.Grant {
119
+ xml.Grantee('xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:type' => 'CanonicalUser') {
120
+ xml.ID grantee
121
+ xml.DisplayName grantee
122
+ }
123
+ xml.Permission permission
124
+ }
125
+ end
126
+ }
127
+ }
128
+ end
129
+
130
+ builder.to_xml
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module S3Light
4
+ VERSION = '1.0.0'
5
+ end
data/lib/s3-light.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http'
4
+ require 'pathname'
5
+ require 'stringio'
6
+ require 'digest'
7
+ require 'base64'
8
+ require 'time'
9
+ require 'openssl/hmac'
10
+ require 'nokogiri'
11
+ require 'concurrent-ruby'
12
+
13
+ Dir.glob(File.join(File.dirname(__FILE__), 's3-light', '**', '*.rb')).each do |file|
14
+ require file
15
+ end
16
+
17
+ module S3Light
18
+ class Error < StandardError; end
19
+ class << self
20
+ def self.configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+ end
28
+ end
data/sig/s3/light.rbs ADDED
@@ -0,0 +1,6 @@
1
+ module S3
2
+ module Light
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
data/tmp/.keep ADDED
File without changes