lifter 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8f28fdb4670f7959c6eef5fff3ac85894c016293
4
+ data.tar.gz: 763a8953d4ba2ed82f969cd28c5cf2150dd0dc81
5
+ SHA512:
6
+ metadata.gz: b3f02a5c37031f345a3a3320ce4155f67fab09f40dcd98d91a5b12b87a081fbad616416779c9ff47a65ae0a4c1b264dc8d746cfff3a43140243d88676925c330
7
+ data.tar.gz: ce2838c2521b7aa19926af9e589199fd51c7158e6fb96618cf7f04d62f7309b07824e76ece43afe277f3828db41668e8eed843d1ce3e5091d452ab5516ba0deb
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ tmp/
2
+ design.txt
3
+ *.gem
4
+ .DS_Store
5
+ Gemfile.lock
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "https://rubygems.org"
2
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2015 Michael Amundson
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,54 @@
1
+ module Lifter
2
+ class Config
3
+ Webhook = Struct.new(:method, :url)
4
+
5
+ def initialize(&definition)
6
+ # Defaults
7
+ @config = {
8
+ host: '127.0.0.1'
9
+ }
10
+
11
+ instance_eval(&definition)
12
+ end
13
+
14
+ def get(key)
15
+ key = key.to_sym
16
+
17
+ raise ArgumentError.new('unknown key') if !@config.has_key?(key)
18
+
19
+ @config[key]
20
+ end
21
+
22
+ def host(host)
23
+ @config[:host] = host
24
+ end
25
+
26
+ def port(port)
27
+ @config[:port] = port.to_i
28
+ end
29
+
30
+ def working_dir(path)
31
+ @config[:working_dir] = path
32
+ end
33
+
34
+ def upload_hash_method(upload_hash_method)
35
+ @config[:upload_hash_method] = upload_hash_method
36
+ end
37
+
38
+ def max_upload_size(max_upload_size)
39
+ @config[:max_upload_size] = max_upload_size
40
+ end
41
+
42
+ def upload_prologue_size(upload_prologue_size)
43
+ @config[:upload_prologue_size] = upload_prologue_size
44
+ end
45
+
46
+ def authorize_webhook(method, url)
47
+ @config[:authorize_webhook] = Webhook.new(method, url)
48
+ end
49
+
50
+ def completed_webhook(method, url)
51
+ @config[:completed_webhook] = Webhook.new(method, url)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,170 @@
1
+ require 'eventmachine'
2
+ require 'http/parser'
3
+
4
+ module Lifter
5
+ class Connection < EventMachine::Connection
6
+ Request = Struct.new(:headers, :params, :remote_ip)
7
+
8
+ PAYLOAD_METHODS = ['put', 'post'].freeze
9
+ CONTENT_TYPE_KEY = 'content-type'.freeze
10
+ MULTIPART_CONTENT_TYPE = 'multipart/form-data'.freeze
11
+
12
+ attr_reader :request
13
+
14
+ def initialize
15
+ super
16
+
17
+ @is_multipart = nil
18
+ @has_payload = nil
19
+
20
+ @multipart_reader = nil
21
+ @inline_reader = nil
22
+
23
+ @server = nil
24
+ @request = Request.new
25
+
26
+ @parser = HTTP::Parser.new
27
+
28
+ @parser.on_message_begin = proc do
29
+ start_request
30
+ end
31
+
32
+ @parser.on_headers_complete = proc do
33
+ process_headers
34
+ start_payload if payload?
35
+ end
36
+
37
+ @parser.on_body = proc do |data|
38
+ receive_payload_data(data) if payload?
39
+ end
40
+
41
+ @parser.on_message_complete = proc do
42
+ finish_request
43
+ end
44
+ end
45
+
46
+ def server=(server)
47
+ raise ArgumentError.new('incorrect type') if !server.is_a?(Server)
48
+
49
+ @server = server
50
+ end
51
+
52
+ def file_manager
53
+ raise StandardError.new('server not defined') if @server.nil?
54
+
55
+ @server.file_manager
56
+ end
57
+
58
+ def receive_data(data)
59
+ @parser << data
60
+ end
61
+
62
+ def unbind
63
+ @payload.cancel if !@payload.nil?
64
+ end
65
+
66
+ def http_version
67
+ @parser.http_version || [1, 0]
68
+ end
69
+
70
+ def remote_ip
71
+ '127.0.0.1'
72
+ end
73
+
74
+ def respond(code, status)
75
+ EventMachine.next_tick do
76
+ response = "HTTP/#{http_version.join('.')} #{code} #{status}"
77
+ send_data(response)
78
+ end
79
+ end
80
+
81
+ def close
82
+ EventMachine.next_tick do
83
+ close_connection(true)
84
+ end
85
+ end
86
+
87
+ private def receive_payload_data(data)
88
+ @payload << data
89
+ end
90
+
91
+ private def start_request
92
+ clear_request
93
+ clear_multipart
94
+ clear_payload
95
+ end
96
+
97
+ private def finish_request
98
+ clear_request
99
+ clear_multipart
100
+ clear_payload
101
+ end
102
+
103
+ private def start_payload
104
+ if multipart?
105
+ start_multipart_payload
106
+ else
107
+ start_inline_payload
108
+ end
109
+ end
110
+
111
+ private def clear_request
112
+ @request = Request.new
113
+ end
114
+
115
+ private def process_headers
116
+ headers = {}
117
+
118
+ @parser.headers.each_pair do |key, value|
119
+ normalized_key = key.strip.downcase
120
+ headers[normalized_key] = value
121
+ end
122
+
123
+ @request.headers = headers
124
+
125
+ @request.remote_ip = remote_ip
126
+ end
127
+
128
+ private def payload?
129
+ return @has_payload if !@has_payload.nil?
130
+
131
+ @has_payload = PAYLOAD_METHODS.include?(@parser.http_method.to_s.downcase)
132
+
133
+ @has_payload
134
+ end
135
+
136
+ private def clear_multipart
137
+ @is_multipart = nil
138
+ end
139
+
140
+ private def multipart?
141
+ return @is_multipart if !@is_multipart.nil?
142
+
143
+ content_type = @request.headers[CONTENT_TYPE_KEY]
144
+
145
+ if content_type.nil? || content_type.empty?
146
+ @is_multipart = false
147
+ else
148
+ @is_multipart = content_type.split(';').first.downcase == MULTIPART_CONTENT_TYPE
149
+ end
150
+
151
+ @is_multipart
152
+ end
153
+
154
+ private def clear_payload
155
+ @has_payload = nil
156
+ @payload = nil
157
+ end
158
+
159
+ private def start_multipart_payload
160
+ content_type = @request.headers[CONTENT_TYPE_KEY]
161
+ multipart_boundary = MultipartParser::Reader.extract_boundary_value(content_type)
162
+
163
+ @payload = Payloads::MultipartPayload.new(self, file_manager, multipart_boundary)
164
+ end
165
+
166
+ private def start_inline_payload
167
+ @payload = Payloads::InlinePayload.new(self, file_manager)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,158 @@
1
+ require 'fileutils'
2
+
3
+ module Lifter
4
+ class FileManager
5
+ DEFAULT_HASH_METHOD = :md5
6
+
7
+ SCRUB_HEADERS = %w(host content-type content-length accept accept-encoding accept-language
8
+ connection)
9
+
10
+ def initialize(config)
11
+ @working_dir = resolve_working_dir(config.get(:working_dir))
12
+
13
+ @authorize_webhook_endpoint = config.get(:authorize_webhook)
14
+ @completed_webhook_endpoint = config.get(:completed_webhook)
15
+
16
+ @work = ThreadPool.new(5)
17
+ @webhooks = ThreadPool.new(5)
18
+ @files = FilePool.new
19
+
20
+ @upload_hash_method = config.get(:upload_hash_method) || DEFAULT_HASH_METHOD
21
+ @upload_prologue_size = config.get(:upload_prologue_size)
22
+ end
23
+
24
+ def open_file(connection, file_id, file_param, file_name)
25
+ @work.push(file_id) do
26
+ file = File.open("#{@working_dir}/progress/#{file_id}", 'wb')
27
+
28
+ file_opts = {
29
+ hash_method: @upload_hash_method,
30
+ prologue_size: @upload_prologue_size,
31
+ original_name: file_name,
32
+ original_request: connection.request,
33
+ param: file_param
34
+ }
35
+
36
+ @files.add(file_id, file, file_opts)
37
+ end
38
+ end
39
+
40
+ def write_file_data(connection, file_id, data)
41
+ @work.push(file_id) do
42
+ file = @files.get(file_id)
43
+ file.write(data)
44
+
45
+ if file.prologue.length >= @upload_prologue_size
46
+ @webhooks.push(file_id) do
47
+ webhook = create_authorize_webhook(connection, file)
48
+ webhook.deliver
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def close_file(connection, file_id)
55
+ @work.push(file_id) do
56
+ file = @files.get(file_id)
57
+
58
+ file.flush
59
+ file.close
60
+
61
+ file.mv("#{@working_dir}/completed/#{file_id}")
62
+
63
+ @files.remove(file_id)
64
+
65
+ # 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.
67
+ if file.prologue.length < @upload_prologue_size
68
+ @webhooks.push(file_id) do
69
+ webhook = create_authorize_webhook(connection, file)
70
+ webhook.deliver
71
+ end
72
+ end
73
+
74
+ @webhooks.push(file_id) do
75
+ webhook = create_completed_webhook(connection, file)
76
+ webhook.deliver
77
+ end
78
+ end
79
+ end
80
+
81
+ def cancel_file(connection, file_id)
82
+ @webhooks.clear(file_id)
83
+
84
+ @work.push(file_id) do
85
+ file = @files.get(file_id)
86
+
87
+ file.close
88
+ file.rm
89
+
90
+ @files.remove(file_id)
91
+ end
92
+ end
93
+
94
+ private def resolve_working_dir(working_dir)
95
+ if working_dir.nil?
96
+ Dir.pwd
97
+ elsif working_dir[0, 1] == '/'
98
+ working_dir
99
+ else
100
+ "#{Dir.pwd}/#{working_dir}"
101
+ end
102
+ end
103
+
104
+ private def create_authorize_webhook(connection, file)
105
+ webhook = Webhook.new(@authorize_webhook_endpoint)
106
+
107
+ headers = file.original_request.headers.dup
108
+ params = file.original_request.params.dup
109
+
110
+ headers = clean_request_headers(headers)
111
+ headers['x-upload-ip'] = file.original_request.remote_ip
112
+
113
+ params[file.param] = {
114
+ file_name: file.original_name,
115
+ file_prologue: file.prologue.length
116
+ }
117
+
118
+ webhook.headers = headers
119
+ webhook.params = params
120
+
121
+ webhook.on_failure do
122
+ connection.cancel
123
+ end
124
+
125
+ webhook
126
+ end
127
+
128
+ private def create_completed_webhook(connection, file)
129
+ webhook = Webhook.new(@completed_webhook_endpoint)
130
+
131
+ headers = file.original_request.headers.dup
132
+ params = file.original_request.params.dup
133
+
134
+ headers = clean_request_headers(headers)
135
+ headers['x-upload-ip'] = file.original_request.remote_ip
136
+
137
+ params[file.param] = {
138
+ file_name: file.original_name,
139
+ file_path: file.full_path,
140
+ file_hash: file.hash
141
+ }
142
+
143
+ webhook.on_failure do
144
+ connection.cancel
145
+ end
146
+
147
+ webhook
148
+ end
149
+
150
+ private def clean_request_headers(headers)
151
+ SCRUB_HEADERS.each do |header_key|
152
+ headers.delete(header_key)
153
+ end
154
+
155
+ headers
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,20 @@
1
+ module Lifter
2
+ class FilePool
3
+ def initialize
4
+ @files = {}
5
+ end
6
+
7
+ def get(file_id)
8
+ @files[file_id]
9
+ end
10
+
11
+ def add(file_id, file, opts = {})
12
+ file_upload = FileUpload.new(file, opts)
13
+ @files[file_id] = file_upload
14
+ end
15
+
16
+ def remove(file_id)
17
+ @files.delete(file_id)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,74 @@
1
+ require 'fileutils'
2
+ require 'digest/sha1'
3
+ require 'digest/sha2'
4
+ require 'digest/md5'
5
+
6
+ module Lifter
7
+ class FileUpload
8
+ DEFAULT_PROLOGUE_SIZE = 10 * 1024
9
+
10
+ attr_reader :prologue, :original_request, :original_name, :param
11
+
12
+ def initialize(file, opts = {})
13
+ @file = file
14
+
15
+ @hash = setup_hash(opts[:hash_method])
16
+
17
+ @prologue_limit = opts[:prologue_size] || DEFAULT_PROLOGUE_SIZE
18
+ @prologue = ''
19
+
20
+ @original_request = opts[:original_request]
21
+ @original_name = opts[:original_name]
22
+ @param = opts[:param]
23
+ end
24
+
25
+ def write(data)
26
+ @file.write(data)
27
+
28
+ @hash << data
29
+
30
+ if @prologue.length < @prologue_limit
31
+ @prologue << data[0, @prologue_limit - @prologue.length]
32
+ end
33
+ end
34
+
35
+ def flush
36
+ @file.flush
37
+ end
38
+
39
+ def close
40
+ @file.close
41
+ end
42
+
43
+ def rm
44
+ FileUtils.rm(full_path)
45
+ end
46
+
47
+ def mv(new_path)
48
+ FileUtils.mv(full_path, new_path)
49
+ end
50
+
51
+ def full_path
52
+ File.expand_path(@file.path)
53
+ end
54
+
55
+ def hash
56
+ @hash.hexdigest
57
+ end
58
+
59
+ private def setup_hash(hash_type)
60
+ case hash_type
61
+ when :md5
62
+ Digest::MD5.new
63
+ when :sha1
64
+ Digest::SHA1.new
65
+ when :sha256
66
+ Digest::SHA256.new
67
+ when :sha512
68
+ Digest::SHA512.new
69
+ else
70
+ Digest::MD5.new
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,10 @@
1
+ module Lifter
2
+ module Payloads
3
+ class InlinePayload
4
+ def initialize(connection, file_manager)
5
+ @connection = connection
6
+ @file_manager = file_manager
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,85 @@
1
+ module Lifter
2
+ module Payloads
3
+ class MultipartPayload
4
+ CurrentPart = Struct.new(:id, :type, :name)
5
+
6
+ def initialize(connection, file_manager, multipart_boundary)
7
+ @connection = connection
8
+ @file_manager = file_manager
9
+ @reader = MultipartParser::Reader.new(multipart_boundary)
10
+
11
+ @current_part = nil
12
+
13
+ @params = {}
14
+
15
+ setup_callbacks
16
+ end
17
+
18
+ def <<(data)
19
+ @reader.write(data)
20
+ end
21
+
22
+ def cancel
23
+ return if !current_part?
24
+
25
+ if current_part.type == :file
26
+ @file_manager.cancel_file(@current_part.id)
27
+ end
28
+
29
+ @current_part = nil
30
+ end
31
+
32
+ def current_part?
33
+ !@current_part.nil?
34
+ end
35
+
36
+ private def setup_callbacks
37
+ @reader.on_part do |part|
38
+ @current_part = CurrentPart.new
39
+
40
+ @current_part.id = SecureRandom.hex(10)
41
+ @current_part.name = part.name
42
+
43
+ if part.filename.nil?
44
+ @current_part.type = :param
45
+ @params[@current_part.name] = ''
46
+ else
47
+ @current_part.type = :file
48
+ @file_manager.open_file(@connection, @current_part.id, @current_part.name, part.filename)
49
+ end
50
+
51
+ part.on_data do |data|
52
+ if @current_part.type == :param
53
+ @params[@current_part.name] << data
54
+ else
55
+ @file_manager.write_file_data(@connection, @current_part.id, data)
56
+ end
57
+ end
58
+
59
+ part.on_end do
60
+ @connection.request.params = @params
61
+
62
+ if @current_part.type == :file
63
+ @file_manager.close_file(@connection, @current_part.id)
64
+ end
65
+
66
+ @current_part = nil
67
+ end
68
+ end
69
+
70
+ @reader.on_end do
71
+ @connection.respond(200, 'OK')
72
+ @connection.close
73
+ end
74
+
75
+ @reader.on_error do |message|
76
+ if !@current_part.nil? && @current_part.type == :file
77
+ @file_manager.cancel_file(@connection, @current_part.id)
78
+ end
79
+
80
+ @current_part = nil
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,26 @@
1
+ require 'eventmachine'
2
+
3
+ module Lifter
4
+ class Server
5
+ attr_reader :config, :file_manager
6
+
7
+ def initialize(&config)
8
+ @config = Config.new(&config)
9
+ @file_manager = FileManager.new(@config)
10
+ end
11
+
12
+ def start
13
+ EventMachine.epoll if EventMachine.epoll?
14
+ EventMachine.kqueue if EventMachine.kqueue?
15
+
16
+ EventMachine.run do
17
+ host = @config.get(:host)
18
+ port = @config.get(:port)
19
+
20
+ EventMachine.start_server(host, port, Connection) do |connection|
21
+ connection.server = self
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end