lifter 0.1.0 → 0.1.1
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/lib/lifter.rb +1 -0
- data/lib/lifter/connection.rb +74 -12
- data/lib/lifter/errors.rb +6 -0
- data/lib/lifter/file_manager.rb +41 -21
- data/lib/lifter/file_upload.rb +42 -2
- data/lib/lifter/payloads/multipart_payload.rb +2 -2
- data/lib/lifter/thread_pool.rb +0 -3
- data/lib/lifter/version.rb +1 -1
- data/lib/lifter/webhook.rb +66 -3
- data/test/test.rb +6 -4
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 795dbedbc9fb64ba72602c68ca1eb1352a1e135d
|
4
|
+
data.tar.gz: 6a4c454a3a9c5005f393c3de0a17f5797e50cf3d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2b8879248daabb268a9b0ac9bafcea9bf752ad1253d7293374b01d81e5a8b50d11d969f7a7123df42314b4193821b6e883c2b39b08a70c2b86a3321cd6fb9752
|
7
|
+
data.tar.gz: f10d0d10f850e39471e7bf001cc898db9dee34a21ba22fc77348fb143b612d5e6352b6e36e2bfb6c6fb2897da839263b246d9229df3433cd2ed711e206605a49
|
data/lib/lifter.rb
CHANGED
data/lib/lifter/connection.rb
CHANGED
@@ -3,9 +3,10 @@ require 'http/parser'
|
|
3
3
|
|
4
4
|
module Lifter
|
5
5
|
class Connection < EventMachine::Connection
|
6
|
-
Request = Struct.new(:headers, :params, :remote_ip)
|
6
|
+
Request = Struct.new(:headers, :params, :remote_ip, :origin)
|
7
7
|
|
8
8
|
PAYLOAD_METHODS = ['put', 'post'].freeze
|
9
|
+
OPTIONS_METHOD = 'options'.freeze
|
9
10
|
CONTENT_TYPE_KEY = 'content-type'.freeze
|
10
11
|
MULTIPART_CONTENT_TYPE = 'multipart/form-data'.freeze
|
11
12
|
|
@@ -16,6 +17,7 @@ module Lifter
|
|
16
17
|
|
17
18
|
@is_multipart = nil
|
18
19
|
@has_payload = nil
|
20
|
+
@options_request = nil
|
19
21
|
|
20
22
|
@multipart_reader = nil
|
21
23
|
@inline_reader = nil
|
@@ -23,6 +25,8 @@ module Lifter
|
|
23
25
|
@server = nil
|
24
26
|
@request = Request.new
|
25
27
|
|
28
|
+
@gracefully_close = false
|
29
|
+
|
26
30
|
@parser = HTTP::Parser.new
|
27
31
|
|
28
32
|
@parser.on_message_begin = proc do
|
@@ -31,7 +35,16 @@ module Lifter
|
|
31
35
|
|
32
36
|
@parser.on_headers_complete = proc do
|
33
37
|
process_headers
|
34
|
-
|
38
|
+
|
39
|
+
if payload?
|
40
|
+
start_payload
|
41
|
+
else
|
42
|
+
if request_method?(:options)
|
43
|
+
handle_options_request
|
44
|
+
else
|
45
|
+
close(gracefully: true)
|
46
|
+
end
|
47
|
+
end
|
35
48
|
end
|
36
49
|
|
37
50
|
@parser.on_body = proc do |data|
|
@@ -60,7 +73,24 @@ module Lifter
|
|
60
73
|
end
|
61
74
|
|
62
75
|
def unbind
|
76
|
+
@payload.cancel if !gracefully_close? && !@payload.nil?
|
77
|
+
end
|
78
|
+
|
79
|
+
def close(opts = {})
|
80
|
+
@gracefully_close = opts[:gracefully] == true
|
81
|
+
|
82
|
+
EventMachine.next_tick do
|
83
|
+
close_connection(true)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def cancel
|
63
88
|
@payload.cancel if !@payload.nil?
|
89
|
+
close(gracefully: true)
|
90
|
+
end
|
91
|
+
|
92
|
+
def gracefully_close?
|
93
|
+
@gracefully_close == true
|
64
94
|
end
|
65
95
|
|
66
96
|
def http_version
|
@@ -71,16 +101,23 @@ module Lifter
|
|
71
101
|
'127.0.0.1'
|
72
102
|
end
|
73
103
|
|
74
|
-
def respond(code, status)
|
104
|
+
def respond(code, status, headers = {}, body = '')
|
105
|
+
return if gracefully_close?
|
106
|
+
|
75
107
|
EventMachine.next_tick do
|
76
108
|
response = "HTTP/#{http_version.join('.')} #{code} #{status}"
|
77
|
-
send_data(response)
|
78
|
-
end
|
79
|
-
end
|
80
109
|
|
81
|
-
|
82
|
-
|
83
|
-
|
110
|
+
headers = {} if headers.empty?
|
111
|
+
headers[:content_length] = body.bytesize if headers[:content_length].nil?
|
112
|
+
headers[:access_control_allow_origin] = @request.origin if !@request.origin.nil?
|
113
|
+
|
114
|
+
formatted_headers = format_response_headers(headers)
|
115
|
+
|
116
|
+
response << "\r\n" << formatted_headers
|
117
|
+
response << "\r\n\r\n"
|
118
|
+
response << body
|
119
|
+
|
120
|
+
send_data(response)
|
84
121
|
end
|
85
122
|
end
|
86
123
|
|
@@ -95,9 +132,6 @@ module Lifter
|
|
95
132
|
end
|
96
133
|
|
97
134
|
private def finish_request
|
98
|
-
clear_request
|
99
|
-
clear_multipart
|
100
|
-
clear_payload
|
101
135
|
end
|
102
136
|
|
103
137
|
private def start_payload
|
@@ -123,6 +157,8 @@ module Lifter
|
|
123
157
|
@request.headers = headers
|
124
158
|
|
125
159
|
@request.remote_ip = remote_ip
|
160
|
+
|
161
|
+
@request.origin = @request.headers['origin']
|
126
162
|
end
|
127
163
|
|
128
164
|
private def payload?
|
@@ -133,6 +169,10 @@ module Lifter
|
|
133
169
|
@has_payload
|
134
170
|
end
|
135
171
|
|
172
|
+
private def request_method?(method)
|
173
|
+
@parser.http_method.to_s.downcase == method.to_s.downcase
|
174
|
+
end
|
175
|
+
|
136
176
|
private def clear_multipart
|
137
177
|
@is_multipart = nil
|
138
178
|
end
|
@@ -166,5 +206,27 @@ module Lifter
|
|
166
206
|
private def start_inline_payload
|
167
207
|
@payload = Payloads::InlinePayload.new(self, file_manager)
|
168
208
|
end
|
209
|
+
|
210
|
+
private def handle_options_request
|
211
|
+
cors_headers = {
|
212
|
+
access_control_allow_methods: 'PUT, POST',
|
213
|
+
access_control_allow_headers: 'accept, content-type',
|
214
|
+
}
|
215
|
+
|
216
|
+
respond(200, 'OK', cors_headers)
|
217
|
+
end
|
218
|
+
|
219
|
+
private def format_response_headers(headers = {})
|
220
|
+
formatted_headers = []
|
221
|
+
|
222
|
+
headers.each_pair do |key, value|
|
223
|
+
formatted_key = key.to_s.strip.downcase.gsub(/[:_\- ]/, '-').squeeze('-').gsub(/[\s]+/, '')
|
224
|
+
formatted_key = formatted_key.split('-').map { |s| s.capitalize }.join('-')
|
225
|
+
|
226
|
+
formatted_headers << "#{formatted_key}: #{value}"
|
227
|
+
end
|
228
|
+
|
229
|
+
formatted_headers.join("\r\n")
|
230
|
+
end
|
169
231
|
end
|
170
232
|
end
|
data/lib/lifter/file_manager.rb
CHANGED
@@ -43,10 +43,7 @@ module Lifter
|
|
43
43
|
file.write(data)
|
44
44
|
|
45
45
|
if file.prologue.length >= @upload_prologue_size
|
46
|
-
|
47
|
-
webhook = create_authorize_webhook(connection, file)
|
48
|
-
webhook.deliver
|
49
|
-
end
|
46
|
+
authorize_file(connection, file_id, file)
|
50
47
|
end
|
51
48
|
end
|
52
49
|
end
|
@@ -60,21 +57,13 @@ module Lifter
|
|
60
57
|
|
61
58
|
file.mv("#{@working_dir}/completed/#{file_id}")
|
62
59
|
|
63
|
-
@files.remove(file_id)
|
64
|
-
|
65
60
|
# In the event the upload was too small for the prologue size to have been met previously,
|
66
|
-
# ensure the authorize webhook is fired off before completed.
|
61
|
+
# ensure the authorize webhook is fired off before the completed webhook.
|
67
62
|
if file.prologue.length < @upload_prologue_size
|
68
|
-
|
69
|
-
webhook = create_authorize_webhook(connection, file)
|
70
|
-
webhook.deliver
|
71
|
-
end
|
63
|
+
authorize_file(connection, file_id, file)
|
72
64
|
end
|
73
65
|
|
74
|
-
|
75
|
-
webhook = create_completed_webhook(connection, file)
|
76
|
-
webhook.deliver
|
77
|
-
end
|
66
|
+
complete_file(connection, file_id, file)
|
78
67
|
end
|
79
68
|
end
|
80
69
|
|
@@ -101,18 +90,28 @@ module Lifter
|
|
101
90
|
end
|
102
91
|
end
|
103
92
|
|
104
|
-
private def
|
93
|
+
private def authorize_file(connection, file_id, file)
|
94
|
+
return if file.authorized? || file.pending_authorization?
|
95
|
+
|
96
|
+
@webhooks.push(file_id) do
|
97
|
+
webhook = create_authorize_webhook(connection, file_id, file)
|
98
|
+
file.pending_authorization
|
99
|
+
webhook.deliver
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
private def create_authorize_webhook(connection, file_id, file)
|
105
104
|
webhook = Webhook.new(@authorize_webhook_endpoint)
|
106
105
|
|
107
106
|
headers = file.original_request.headers.dup
|
108
107
|
params = file.original_request.params.dup
|
109
108
|
|
110
|
-
headers =
|
109
|
+
headers = scrub_request_headers(headers)
|
111
110
|
headers['x-upload-ip'] = file.original_request.remote_ip
|
112
111
|
|
113
112
|
params[file.param] = {
|
114
113
|
file_name: file.original_name,
|
115
|
-
file_prologue: file.prologue
|
114
|
+
file_prologue: file.prologue
|
116
115
|
}
|
117
116
|
|
118
117
|
webhook.headers = headers
|
@@ -120,18 +119,30 @@ module Lifter
|
|
120
119
|
|
121
120
|
webhook.on_failure do
|
122
121
|
connection.cancel
|
122
|
+
cancel_file(connection, file_id)
|
123
|
+
end
|
124
|
+
|
125
|
+
webhook.on_success do
|
126
|
+
file.authorize
|
123
127
|
end
|
124
128
|
|
125
129
|
webhook
|
126
130
|
end
|
127
131
|
|
128
|
-
private def
|
132
|
+
private def complete_file(connection, file_id, file)
|
133
|
+
@webhooks.push(file_id) do
|
134
|
+
webhook = create_completed_webhook(connection, file_id, file)
|
135
|
+
webhook.deliver
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
private def create_completed_webhook(connection, file_id, file)
|
129
140
|
webhook = Webhook.new(@completed_webhook_endpoint)
|
130
141
|
|
131
142
|
headers = file.original_request.headers.dup
|
132
143
|
params = file.original_request.params.dup
|
133
144
|
|
134
|
-
headers =
|
145
|
+
headers = scrub_request_headers(headers)
|
135
146
|
headers['x-upload-ip'] = file.original_request.remote_ip
|
136
147
|
|
137
148
|
params[file.param] = {
|
@@ -140,14 +151,23 @@ module Lifter
|
|
140
151
|
file_hash: file.hash
|
141
152
|
}
|
142
153
|
|
154
|
+
webhook.headers = headers
|
155
|
+
webhook.params = params
|
156
|
+
|
143
157
|
webhook.on_failure do
|
144
158
|
connection.cancel
|
159
|
+
@files.remove(file_id)
|
160
|
+
end
|
161
|
+
|
162
|
+
webhook.on_success do
|
163
|
+
connection.respond(200, 'OK')
|
164
|
+
@files.remove(file_id)
|
145
165
|
end
|
146
166
|
|
147
167
|
webhook
|
148
168
|
end
|
149
169
|
|
150
|
-
private def
|
170
|
+
private def scrub_request_headers(headers)
|
151
171
|
SCRUB_HEADERS.each do |header_key|
|
152
172
|
headers.delete(header_key)
|
153
173
|
end
|
data/lib/lifter/file_upload.rb
CHANGED
@@ -10,7 +10,13 @@ module Lifter
|
|
10
10
|
attr_reader :prologue, :original_request, :original_name, :param
|
11
11
|
|
12
12
|
def initialize(file, opts = {})
|
13
|
+
@mutex = Mutex.new
|
14
|
+
|
13
15
|
@file = file
|
16
|
+
@path = file.path
|
17
|
+
|
18
|
+
@authorized = false
|
19
|
+
@pending_authorization = false
|
14
20
|
|
15
21
|
@hash = setup_hash(opts[:hash_method])
|
16
22
|
|
@@ -37,25 +43,59 @@ module Lifter
|
|
37
43
|
end
|
38
44
|
|
39
45
|
def close
|
40
|
-
@file.close
|
46
|
+
@file.close if !@file.closed?
|
41
47
|
end
|
42
48
|
|
43
49
|
def rm
|
44
50
|
FileUtils.rm(full_path)
|
51
|
+
@path = nil
|
45
52
|
end
|
46
53
|
|
47
54
|
def mv(new_path)
|
48
55
|
FileUtils.mv(full_path, new_path)
|
56
|
+
@path = new_path
|
49
57
|
end
|
50
58
|
|
51
59
|
def full_path
|
52
|
-
File.expand_path(@
|
60
|
+
File.expand_path(@path)
|
53
61
|
end
|
54
62
|
|
55
63
|
def hash
|
56
64
|
@hash.hexdigest
|
57
65
|
end
|
58
66
|
|
67
|
+
def authorize
|
68
|
+
@mutex.synchronize do
|
69
|
+
@authorized = true
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def pending_authorization
|
74
|
+
@mutex.synchronize do
|
75
|
+
@pending_authorization = true
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def pending_authorization?
|
80
|
+
pending = false
|
81
|
+
|
82
|
+
@mutex.synchronize do
|
83
|
+
pending = @pending_authorization == true
|
84
|
+
end
|
85
|
+
|
86
|
+
pending
|
87
|
+
end
|
88
|
+
|
89
|
+
def authorized?
|
90
|
+
authorized = false
|
91
|
+
|
92
|
+
@mutex.synchronize do
|
93
|
+
authorized = @authorized == true
|
94
|
+
end
|
95
|
+
|
96
|
+
authorized
|
97
|
+
end
|
98
|
+
|
59
99
|
private def setup_hash(hash_type)
|
60
100
|
case hash_type
|
61
101
|
when :md5
|
@@ -23,7 +23,7 @@ module Lifter
|
|
23
23
|
return if !current_part?
|
24
24
|
|
25
25
|
if current_part.type == :file
|
26
|
-
@file_manager.cancel_file(@current_part.id)
|
26
|
+
@file_manager.cancel_file(@connection, @current_part.id)
|
27
27
|
end
|
28
28
|
|
29
29
|
@current_part = nil
|
@@ -69,7 +69,7 @@ module Lifter
|
|
69
69
|
|
70
70
|
@reader.on_end do
|
71
71
|
@connection.respond(200, 'OK')
|
72
|
-
@connection.close
|
72
|
+
@connection.close(gracefully: true)
|
73
73
|
end
|
74
74
|
|
75
75
|
@reader.on_error do |message|
|
data/lib/lifter/thread_pool.rb
CHANGED
data/lib/lifter/version.rb
CHANGED
data/lib/lifter/webhook.rb
CHANGED
@@ -2,6 +2,32 @@ require 'http'
|
|
2
2
|
|
3
3
|
module Lifter
|
4
4
|
class Webhook
|
5
|
+
# Courtesy of: http://dev.mensfeld.pl/2012/01/converting-nested-hash-into-http-url-params-hash-version-in-ruby/
|
6
|
+
module ParamNester
|
7
|
+
def self.encode(value, key = nil, out_hash = {})
|
8
|
+
case value
|
9
|
+
when Hash
|
10
|
+
value.each { |k,v| encode(v, append_key(key, k), out_hash) }
|
11
|
+
out_hash
|
12
|
+
when Array
|
13
|
+
value.each { |v| encode(v, "#{key}[]", out_hash) }
|
14
|
+
out_hash
|
15
|
+
when nil
|
16
|
+
''
|
17
|
+
else
|
18
|
+
out_hash[key] = value
|
19
|
+
out_hash
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.append_key(root_key, key)
|
24
|
+
root_key.nil? ? :"#{key}" : :"#{root_key}[#{key.to_s}]"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
RETRY_CODES = [500, 502, 503, 504]
|
29
|
+
RETRY_LIMIT = 3
|
30
|
+
|
5
31
|
attr_reader :url, :method, :headers, :params
|
6
32
|
|
7
33
|
def initialize(endpoint)
|
@@ -9,14 +35,24 @@ module Lifter
|
|
9
35
|
@method = endpoint.method
|
10
36
|
@headers = {}
|
11
37
|
@params = {}
|
38
|
+
|
39
|
+
@retry_count = 0
|
40
|
+
@retry_limit = RETRY_LIMIT
|
41
|
+
|
42
|
+
@on_success = nil
|
43
|
+
@on_failure = nil
|
12
44
|
end
|
13
45
|
|
14
46
|
def headers=(headers)
|
15
47
|
@headers = headers
|
16
48
|
end
|
17
49
|
|
18
|
-
def params=(params)
|
19
|
-
@params = params
|
50
|
+
def params=(params = {})
|
51
|
+
@params = ParamNester.encode(params)
|
52
|
+
end
|
53
|
+
|
54
|
+
def on_success(&block)
|
55
|
+
@on_success = block
|
20
56
|
end
|
21
57
|
|
22
58
|
def on_failure(&block)
|
@@ -24,6 +60,33 @@ module Lifter
|
|
24
60
|
end
|
25
61
|
|
26
62
|
def deliver
|
63
|
+
begin
|
64
|
+
start_delivery
|
65
|
+
rescue Errors::WebhookFailed => e
|
66
|
+
@on_failure.call if !@on_failure.nil?
|
67
|
+
end
|
68
|
+
|
69
|
+
@on_success.call if !@on_success.nil?
|
70
|
+
end
|
71
|
+
|
72
|
+
private def start_delivery
|
73
|
+
begin
|
74
|
+
response = finish_delivery
|
75
|
+
rescue StandardError => e
|
76
|
+
raise Errors::WebhookFailed.new
|
77
|
+
end
|
78
|
+
|
79
|
+
return if response.code == 200
|
80
|
+
|
81
|
+
if RETRY_CODES.include?(response.code) && @retry_count < @retry_limit
|
82
|
+
@retry_count += 1
|
83
|
+
start_delivery
|
84
|
+
else
|
85
|
+
raise Errors::WebhookFailed.new
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
private def finish_delivery
|
27
90
|
http_stub = HTTP.headers(@headers)
|
28
91
|
|
29
92
|
case @method.to_sym
|
@@ -34,7 +97,7 @@ module Lifter
|
|
34
97
|
when :put
|
35
98
|
http_stub.put(@url, form: @params)
|
36
99
|
else
|
37
|
-
raise
|
100
|
+
raise Errors::InvalidWebhookMethod.new
|
38
101
|
end
|
39
102
|
end
|
40
103
|
end
|
data/test/test.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
lib = File.expand_path("../../lib", __FILE__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
1
3
|
require 'lifter'
|
2
4
|
|
3
5
|
server = Lifter::Server.new do |config|
|
@@ -13,14 +15,14 @@ server = Lifter::Server.new do |config|
|
|
13
15
|
working_dir 'tmp/uploads'
|
14
16
|
|
15
17
|
# Define maximum size in bytes of a file upload. Files larger than this will be automatically
|
16
|
-
# removed, the connection closed, and no
|
18
|
+
# removed, the connection closed, and no completed webhook will fire.
|
17
19
|
#
|
18
20
|
max_upload_size 500 * 1024 * 1024
|
19
21
|
|
20
22
|
# Specify desired digest type for file uploads. Passed in uploaded webhook after upload completes.
|
21
23
|
# Possible options: md5, sha1, sha256, sha512.
|
22
24
|
#
|
23
|
-
upload_hash_method :
|
25
|
+
upload_hash_method :sha256
|
24
26
|
|
25
27
|
# Configure maximum number of bytes to pass along in authorize webhook.
|
26
28
|
#
|
@@ -38,7 +40,7 @@ server = Lifter::Server.new do |config|
|
|
38
40
|
# as the first <prologue_limit> bytes of data is received. Non-200 responses for one part will not
|
39
41
|
# remove data from other parts, although the connection will still be terminated.
|
40
42
|
#
|
41
|
-
authorize_webhook :post, 'http://127.0.0.1:
|
43
|
+
authorize_webhook :post, 'http://127.0.0.1:3000/uploads/authorize'
|
42
44
|
|
43
45
|
# A request to this webhook is made once a single file upload completes. In the event the upload
|
44
46
|
# is multipart with multiple files, this endpoint will be called once for each file, upon
|
@@ -46,7 +48,7 @@ server = Lifter::Server.new do |config|
|
|
46
48
|
#
|
47
49
|
# An authorize webhook is always sent prior to sending this webhook.
|
48
50
|
#
|
49
|
-
completed_webhook :post, 'http://127.0.0.1:
|
51
|
+
completed_webhook :post, 'http://127.0.0.1:3000/uploads/complete'
|
50
52
|
end
|
51
53
|
|
52
54
|
server.start
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lifter
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Michael Amundson
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-
|
11
|
+
date: 2015-11-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: http_parser.rb
|
@@ -80,6 +80,7 @@ files:
|
|
80
80
|
- lib/lifter.rb
|
81
81
|
- lib/lifter/config.rb
|
82
82
|
- lib/lifter/connection.rb
|
83
|
+
- lib/lifter/errors.rb
|
83
84
|
- lib/lifter/file_manager.rb
|
84
85
|
- lib/lifter/file_pool.rb
|
85
86
|
- lib/lifter/file_upload.rb
|
@@ -115,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
115
116
|
version: '0'
|
116
117
|
requirements: []
|
117
118
|
rubyforge_project:
|
118
|
-
rubygems_version: 2.4.5
|
119
|
+
rubygems_version: 2.4.5.1
|
119
120
|
signing_key:
|
120
121
|
specification_version: 4
|
121
122
|
summary: Painless file uploads
|