lifter 0.1.0

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