s3-light 1.0.0

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