httphere 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.
- data/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.rdoc +21 -0
- data/Rakefile +55 -0
- data/VERSION +1 -0
- data/bin/httphere +624 -0
- data/httphere.gemspec +62 -0
- metadata +86 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 BehindLogic
|
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.
|
data/README.rdoc
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
= httphere
|
2
|
+
|
3
|
+
httphere is a very small and simple ruby command-line HTTP file server.
|
4
|
+
|
5
|
+
= To work on
|
6
|
+
|
7
|
+
* Improve content-negotiation behind the scenes. For example, a file found to be "application/x-ruby" could just as well be transferred as "text/plain".
|
8
|
+
|
9
|
+
== Note on Patches/Pull Requests
|
10
|
+
|
11
|
+
* Fork the project.
|
12
|
+
* Make your feature addition or bug fix.
|
13
|
+
* Add tests for it. This is important so I don't break it in a
|
14
|
+
future version unintentionally.
|
15
|
+
* Commit, do not mess with rakefile, version, or history.
|
16
|
+
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
17
|
+
* Send me a pull request. Bonus points for topic branches.
|
18
|
+
|
19
|
+
== Copyright
|
20
|
+
|
21
|
+
Copyright (c) 2010 BehindLogic. See LICENSE for details.
|
data/Rakefile
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
begin
|
5
|
+
require 'jeweler'
|
6
|
+
Jeweler::Tasks.new do |gem|
|
7
|
+
gem.name = "httphere"
|
8
|
+
gem.summary = %Q{A simple Ruby HTTP file server}
|
9
|
+
gem.description = %Q{httphere is a very small and simple ruby command-line HTTP file server.}
|
10
|
+
gem.email = "gems@behindlogic.com"
|
11
|
+
gem.homepage = "http://dcparker.github.com/httphere"
|
12
|
+
gem.authors = ["Daniel Parker"]
|
13
|
+
gem.add_dependency 'shared-mime-info'
|
14
|
+
gem.add_dependency 'chardet'
|
15
|
+
gem.post_install_message = "\n\033[34mhttphere wants to detect MIME-types! For this\nyou'll need to install the open-source shared-mime-info.\nAssuming you're on a Mac, you can simply run:\n\033[0m \033[31msudo port install shared-mime-info\033[0m\n\n * This gem has not been tested on Windows.\n\n"
|
16
|
+
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
17
|
+
end
|
18
|
+
Jeweler::GemcutterTasks.new
|
19
|
+
rescue LoadError
|
20
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
21
|
+
end
|
22
|
+
|
23
|
+
require 'rake/testtask'
|
24
|
+
Rake::TestTask.new(:test) do |test|
|
25
|
+
test.libs << 'lib' << 'test'
|
26
|
+
test.pattern = 'test/**/test_*.rb'
|
27
|
+
test.verbose = true
|
28
|
+
end
|
29
|
+
|
30
|
+
begin
|
31
|
+
require 'rcov/rcovtask'
|
32
|
+
Rcov::RcovTask.new do |test|
|
33
|
+
test.libs << 'test'
|
34
|
+
test.pattern = 'test/**/test_*.rb'
|
35
|
+
test.verbose = true
|
36
|
+
end
|
37
|
+
rescue LoadError
|
38
|
+
task :rcov do
|
39
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
task :test => :check_dependencies
|
44
|
+
|
45
|
+
task :default => :test
|
46
|
+
|
47
|
+
require 'rake/rdoctask'
|
48
|
+
Rake::RDocTask.new do |rdoc|
|
49
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
50
|
+
|
51
|
+
rdoc.rdoc_dir = 'rdoc'
|
52
|
+
rdoc.title = "httphere #{version}"
|
53
|
+
rdoc.rdoc_files.include('README*')
|
54
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
55
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
1.0.0
|
data/bin/httphere
ADDED
@@ -0,0 +1,624 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$VERSION = '0.0.1'
|
4
|
+
$DEBUG = false
|
5
|
+
|
6
|
+
require 'socket'
|
7
|
+
$options = {}
|
8
|
+
|
9
|
+
ENV['XDG_DATA_DIRS'] = (ENV['XDG_DATA_DIRS'].to_s.split(/:/) << '/opt/local/share').join(':')
|
10
|
+
|
11
|
+
require 'optparse'
|
12
|
+
optparse = OptionParser.new do |opts|
|
13
|
+
opts.banner = "Usage: #{$0} [options]"
|
14
|
+
|
15
|
+
$options[:port] = 80
|
16
|
+
opts.on( '-p PORT', '--port PORT', "Run on PORT. Defaults to #{$options[:port]}" ) do |port|
|
17
|
+
$options[:port] = port.to_i
|
18
|
+
end
|
19
|
+
|
20
|
+
# Gets the IP address for the default
|
21
|
+
# $options[:address] = Socket.getaddrinfo(Socket.gethostname, nil, 'AF_INET')[0][2]
|
22
|
+
$options[:address] = '0.0.0.0'
|
23
|
+
opts.on( '-a ADDRESS', '--address ADDRESS', "Listen on ADDRESS ip. Defaults to [#{$options[:address]}]") do |address|
|
24
|
+
$options[:address] = address
|
25
|
+
end
|
26
|
+
|
27
|
+
$options[:https_domain] = $options[:address]
|
28
|
+
end
|
29
|
+
|
30
|
+
optparse.parse!
|
31
|
+
|
32
|
+
require 'socket'
|
33
|
+
require 'openssl'
|
34
|
+
|
35
|
+
class ::OpenSSL::SSL::SSLSocket
|
36
|
+
alias :read_nonblock :readpartial
|
37
|
+
end
|
38
|
+
|
39
|
+
class EventMachineMini
|
40
|
+
DEFAULT_SSL_OPTIONS = Hash.new do |h,k|
|
41
|
+
case k
|
42
|
+
when :SSLCertificate
|
43
|
+
h[k] = OpenSSL::X509::Certificate.new(File.read(h[:SSLCertificateFile]))
|
44
|
+
when :SSLPrivateKey
|
45
|
+
h[k] = OpenSSL::PKey::RSA.new(File.read(h[:SSLPrivateKeyFile]))
|
46
|
+
else
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
DEFAULT_SSL_OPTIONS.merge!(
|
52
|
+
:GenerateSSLCert => false,
|
53
|
+
:ServerSoftware => "Ruby TCP Router OpenSSL/#{::OpenSSL::OPENSSL_VERSION.split[1]}",
|
54
|
+
:SSLCertName => [['CN', $options[:https_domain]]],
|
55
|
+
:SSLCertComment => "Generated by Ruby/OpenSSL",
|
56
|
+
:SSLCertificateFile => 'cert.pem',
|
57
|
+
:SSLPrivateKeyFile => 'key.pem',
|
58
|
+
:SSLClientCA => nil,
|
59
|
+
:SSLExtraChainCert => nil,
|
60
|
+
:SSLCACertificateFile => 'cacert.pem',
|
61
|
+
:SSLCACertificatePath => nil,
|
62
|
+
:SSLCertificateStore => nil,
|
63
|
+
:SSLVerifyClient => ::OpenSSL::SSL::VERIFY_PEER,
|
64
|
+
:SSLVerifyDepth => 1,
|
65
|
+
:SSLVerifyCallback => nil, # custom verification
|
66
|
+
:SSLTimeout => nil,
|
67
|
+
:SSLOptions => nil,
|
68
|
+
:SSLStartImmediately => true
|
69
|
+
)
|
70
|
+
|
71
|
+
class << self
|
72
|
+
def ssl_config(config=DEFAULT_SSL_OPTIONS)
|
73
|
+
@ssl_config ||= config
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def running?
|
78
|
+
Thread.current[:status] == :running
|
79
|
+
end
|
80
|
+
def stop!
|
81
|
+
Thread.current[:status] = :stop
|
82
|
+
end
|
83
|
+
|
84
|
+
attr_reader :router
|
85
|
+
def initialize(routes={})
|
86
|
+
@router = {}
|
87
|
+
# Set up the listening sockets
|
88
|
+
(routes[:listen] || {}).each do |ip_port,instantiate_klass|
|
89
|
+
ip, port = ip_port.split(/:/)
|
90
|
+
socket = TCPServer.new(ip, port)
|
91
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
|
92
|
+
@router[socket] = instantiate_klass
|
93
|
+
# puts "Listening on #{ip_port} for #{instantiate_klass.name} messages..."
|
94
|
+
end
|
95
|
+
# Set up the listening SSL sockets
|
96
|
+
(routes[:ssl_listen] || {}).each do |ip_port,instantiate_klass|
|
97
|
+
ip, port = ip_port.split(/:/)
|
98
|
+
socket = TCPServer.new(ip, port)
|
99
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
|
100
|
+
|
101
|
+
ssl_socket = ::OpenSSL::SSL::SSLServer.new(socket, ssl_context)
|
102
|
+
ssl_socket.start_immediately = self.class.ssl_config[:SSLStartImmediately]
|
103
|
+
|
104
|
+
@router[ssl_socket] = instantiate_klass
|
105
|
+
# puts "Listening on #{ip_port} (SSL) for #{instantiate_klass.name} messages..."
|
106
|
+
end
|
107
|
+
# Set up the connect sockets
|
108
|
+
(routes[:connect] || {}).each do |ip_port,args|
|
109
|
+
args = [args] unless args.is_a?(Array); instantiate_klass = args.shift
|
110
|
+
ip, port = ip_port.split(/:/)
|
111
|
+
socket = TCPSocket.new(ip, port)
|
112
|
+
clients[socket] = instantiate_klass.new(self,socket,*args)
|
113
|
+
# puts "Connecting to #{ip_port} for #{instantiate_klass.name} messages..."
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def run
|
118
|
+
Thread.current[:status] = :running
|
119
|
+
# trap("INT") { stop! } # This will end the event loop within 0.5 seconds when you hit Ctrl+C
|
120
|
+
loop do
|
121
|
+
# log "tick #{Thread.current[:status]}\n" if $DEBUG
|
122
|
+
|
123
|
+
# Clean up any closed clients
|
124
|
+
clients.each_key do |sock|
|
125
|
+
if sock.closed?
|
126
|
+
conn = clients.delete(sock)
|
127
|
+
conn.upon_unbind if conn.respond_to?(:upon_unbind)
|
128
|
+
if conn.respond_to?(:key)
|
129
|
+
connections_by_key.delete(conn.key) rescue nil
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
if !running?
|
135
|
+
unless listen_sockets.empty?
|
136
|
+
# puts "Closing all listening ports."
|
137
|
+
shutdown_listeners!
|
138
|
+
end
|
139
|
+
if client_sockets.empty?
|
140
|
+
# It's the next time around after we closed all the client connections.
|
141
|
+
break
|
142
|
+
else
|
143
|
+
# puts "Closing all client connections."
|
144
|
+
close_all_clients!
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
# puts "Listening to #{listen_sockets.length} sockets: #{listen_sockets.inspect}"
|
149
|
+
begin
|
150
|
+
event = select(listen_sockets + client_sockets,nil,nil,0.5)
|
151
|
+
rescue IOError
|
152
|
+
next
|
153
|
+
end
|
154
|
+
if event.nil? # nil would be a timeout, we'd do nothing and start loop over. Of course here we really have no timeout...
|
155
|
+
@router.values.each { |klass|
|
156
|
+
klass.tick if klass.respond_to?(:tick)
|
157
|
+
}
|
158
|
+
else
|
159
|
+
event[0].each do |sock| # Iterate through all sockets that have pending activity
|
160
|
+
# puts "Event on socket #{sock.inspect}"
|
161
|
+
if listen_sockets.include?(sock) # Received a new connection to a listening socket
|
162
|
+
sock = accept_client(sock)
|
163
|
+
clients[sock].upon_new_connection if clients[sock].respond_to?(:upon_new_connection)
|
164
|
+
else # Activity on a client-connected socket
|
165
|
+
if sock.eof? # Socket's been closed by the client
|
166
|
+
log "Connection #{clients[sock].to_s} was closed by the client.\n"
|
167
|
+
sock.close
|
168
|
+
clients[sock].upon_unbind if clients[sock].respond_to?(:upon_unbind)
|
169
|
+
client = clients[sock]
|
170
|
+
clients.delete(sock)
|
171
|
+
else # Data in from the client
|
172
|
+
catch :stop_reading do
|
173
|
+
# puts "Reading data from socket #{sock.inspect} / #{clients[sock].inspect}"
|
174
|
+
begin
|
175
|
+
if sock.respond_to?(:read_nonblock)
|
176
|
+
# puts "read_nonblock"
|
177
|
+
10.times {
|
178
|
+
data = sock.read_nonblock(4096)
|
179
|
+
clients[sock].receive_data(data)
|
180
|
+
}
|
181
|
+
else
|
182
|
+
# puts "sysread"
|
183
|
+
data = sock.sysread(4096)
|
184
|
+
clients[sock].receive_data(data)
|
185
|
+
end
|
186
|
+
rescue Errno::EAGAIN, Errno::EWOULDBLOCK, EOFError => e
|
187
|
+
# no-op. This will likely happen after every request, but that's expected. It ensures that we're done with the request's data.
|
188
|
+
rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, IOError => e
|
189
|
+
log "Closed Err: #{e.inspect}\n"
|
190
|
+
sock.close
|
191
|
+
clients[sock].upon_unbind if clients[sock].respond_to?(:upon_unbind)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def clients
|
202
|
+
@clients ||= {}
|
203
|
+
end
|
204
|
+
|
205
|
+
def connections_by_key
|
206
|
+
@connections_by_key ||= {}
|
207
|
+
end
|
208
|
+
|
209
|
+
def close_all_clients!
|
210
|
+
# puts "Closing #{client_sockets.length} client connections..."
|
211
|
+
client_sockets.each { |socket| socket.close rescue nil }
|
212
|
+
end
|
213
|
+
def shutdown_listeners!
|
214
|
+
# puts "Shutting down #{listen_sockets.length} listeners..."
|
215
|
+
listen_sockets.each { |socket| socket.close rescue nil }
|
216
|
+
end
|
217
|
+
|
218
|
+
private
|
219
|
+
def listen_sockets
|
220
|
+
@router.keys.select {|socket| !socket.closed? }
|
221
|
+
end
|
222
|
+
|
223
|
+
def accept_client(source_socket)
|
224
|
+
client_socket = source_socket.accept
|
225
|
+
connection = @router[source_socket].new(self,client_socket)
|
226
|
+
clients[client_socket] = connection
|
227
|
+
end
|
228
|
+
|
229
|
+
def client_sockets
|
230
|
+
@clients.keys
|
231
|
+
end
|
232
|
+
|
233
|
+
def ssl_context
|
234
|
+
unless @ssl_context
|
235
|
+
@ssl_context = OpenSSL::SSL::SSLContext.new
|
236
|
+
@ssl_context.client_ca = self.class.ssl_config[:SSLClientCA]
|
237
|
+
@ssl_context.ca_file = self.class.ssl_config[:SSLCACertificateFile]
|
238
|
+
@ssl_context.ca_path = self.class.ssl_config[:SSLCACertificatePath]
|
239
|
+
@ssl_context.extra_chain_cert = self.class.ssl_config[:SSLExtraChainCert]
|
240
|
+
@ssl_context.cert_store = self.class.ssl_config[:SSLCertificateStore]
|
241
|
+
@ssl_context.verify_mode = self.class.ssl_config[:SSLVerifyClient]
|
242
|
+
@ssl_context.verify_depth = self.class.ssl_config[:SSLVerifyDepth]
|
243
|
+
@ssl_context.verify_callback = self.class.ssl_config[:SSLVerifyCallback]
|
244
|
+
@ssl_context.timeout = self.class.ssl_config[:SSLTimeout]
|
245
|
+
@ssl_context.options = self.class.ssl_config[:SSLOptions]
|
246
|
+
if self.class.ssl_config[:GenerateSSLCert]
|
247
|
+
puts "Generating SSL Certificate..."
|
248
|
+
@ssl_context.key = OpenSSL::PKey::RSA.generate(4096)
|
249
|
+
ca = OpenSSL::X509::Name.parse("/C=US/ST=Michigan/O=BehindLogic/CN=behindlogic.com/emailAddress=cert@desktopconnect.com")
|
250
|
+
cert = OpenSSL::X509::Certificate.new
|
251
|
+
cert.version = 2
|
252
|
+
cert.serial = 1
|
253
|
+
cert.subject = ca
|
254
|
+
cert.issuer = ca
|
255
|
+
cert.public_key = @ssl_context.key.public_key
|
256
|
+
cert.not_before = Time.now
|
257
|
+
cert.not_after = Time.now + 3600 # this http session should last no longer than 1 hour
|
258
|
+
@ssl_context.cert = cert
|
259
|
+
else
|
260
|
+
@ssl_context.cert = self.class.ssl_config[:SSLCertificate]
|
261
|
+
@ssl_context.key = self.class.ssl_config[:SSLPrivateKey]
|
262
|
+
end
|
263
|
+
end
|
264
|
+
@ssl_context
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
require 'base64'
|
269
|
+
|
270
|
+
module EventParsers
|
271
|
+
|
272
|
+
# Implement this by including it in a class and call receive_data on every read event.
|
273
|
+
# Callbacks available:
|
274
|
+
# upon_new_request(request) # after first HTTP line
|
275
|
+
# receive_header(request, header) # after each header is received
|
276
|
+
# upon_headers_finished(request) # after all headers are received
|
277
|
+
# process_request(request) # after the full request is received
|
278
|
+
module Http11Parser
|
279
|
+
module BasicAuth
|
280
|
+
::UnparsableBasicAuth = Class.new(RuntimeError)
|
281
|
+
|
282
|
+
class << self
|
283
|
+
def parse(basic_auth_string)
|
284
|
+
# Do the special decoding here
|
285
|
+
if basic_auth_string =~ /^Basic (.*)$/
|
286
|
+
auth_string = $1
|
287
|
+
auth_plain = Base64.decode64(auth_string)
|
288
|
+
return auth_plain.split(/:/,2)
|
289
|
+
else
|
290
|
+
warn "Bad Auth string!"
|
291
|
+
raise UnparsableBasicAuth
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
def initialize(username, password)
|
297
|
+
@username = username
|
298
|
+
@password = password
|
299
|
+
end
|
300
|
+
|
301
|
+
def to_s
|
302
|
+
# Do the special encoding here
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
class HeaderAndEntityStateStore
|
307
|
+
attr_accessor :state, :delimiter, :linebuffer, :textbuffer, :entity_size, :entity_pos, :bogus_lines
|
308
|
+
|
309
|
+
def initialize(state, delimiter)
|
310
|
+
@state = state
|
311
|
+
@delimiter = delimiter
|
312
|
+
reset!
|
313
|
+
end
|
314
|
+
|
315
|
+
def reset!
|
316
|
+
@linebuffer = []
|
317
|
+
@textbuffer = []
|
318
|
+
@entity_size = nil
|
319
|
+
@entity_pos = 0
|
320
|
+
@bogus_lines = 0
|
321
|
+
end
|
322
|
+
|
323
|
+
def entity?
|
324
|
+
!entity_size.nil? && entity_size > 0
|
325
|
+
end
|
326
|
+
|
327
|
+
def bogus_line!(ln=nil)
|
328
|
+
log "bogus line:\n#{ln}\n" if ln && $DEBUG
|
329
|
+
@bogus_lines += 1
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
class Request
|
334
|
+
attr_accessor :connection, :method, :http_version, :resource_uri, :query_params, :headers, :entity, :response
|
335
|
+
def ready!
|
336
|
+
@ready = true
|
337
|
+
end
|
338
|
+
def ready?
|
339
|
+
!!@ready
|
340
|
+
end
|
341
|
+
|
342
|
+
def initialize(request_params={})
|
343
|
+
@http_version = request_params[:http_version] if request_params.has_key? :http_version
|
344
|
+
@resource_uri = request_params[:resource_uri] if request_params.has_key? :resource_uri
|
345
|
+
@headers = request_params[:headers] if request_params.has_key? :headers
|
346
|
+
@entity = request_params[:entity] if request_params.has_key? :entity
|
347
|
+
|
348
|
+
@headers ||= {}
|
349
|
+
@entity ||= ''
|
350
|
+
end
|
351
|
+
|
352
|
+
def inspect
|
353
|
+
"HTTP Request:\n\t#{@method} #{@resource_uri} HTTP/#{@http_version}\n\t#{@headers.map {|k,v| "#{k}: #{v}"}.join("\n\t")}\n\n\t#{@entity.to_s.gsub(/\n/,"\n\t")}"
|
354
|
+
end
|
355
|
+
|
356
|
+
def has_entity?
|
357
|
+
@entity != ''
|
358
|
+
end
|
359
|
+
|
360
|
+
def basic_auth
|
361
|
+
BasicAuth.parse(@headers['authorization']) if @headers['authorization']
|
362
|
+
end
|
363
|
+
|
364
|
+
def params
|
365
|
+
query_params
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def self.included(base)
|
370
|
+
base.extend ClassMethods
|
371
|
+
end
|
372
|
+
module ClassMethods
|
373
|
+
def request_klass(klass=nil)
|
374
|
+
@request_klass ||= (klass || Request)
|
375
|
+
end
|
376
|
+
end
|
377
|
+
|
378
|
+
attr_reader :socket
|
379
|
+
|
380
|
+
def request_backlog
|
381
|
+
@request_backlog ||= []
|
382
|
+
end
|
383
|
+
|
384
|
+
def current_request
|
385
|
+
request_backlog.first
|
386
|
+
end
|
387
|
+
def parsing_request
|
388
|
+
request_backlog.last
|
389
|
+
end
|
390
|
+
|
391
|
+
HttpResponseRE = /\AHTTP\/(1.[01]) ([\d]{3})/i
|
392
|
+
HttpRequestRE = /^(GET|POST|PUT|DELETE) (\/.*) HTTP\/([\d\.]+)[\r\n]?$/i
|
393
|
+
BlankLineRE = /^[\n\r]+$/
|
394
|
+
|
395
|
+
def receive_data(data)
|
396
|
+
return unless (data and data.length > 0)
|
397
|
+
|
398
|
+
@last_activity = Time.now
|
399
|
+
|
400
|
+
case parser.state
|
401
|
+
when :init
|
402
|
+
if ix = data.index("\n")
|
403
|
+
parser.linebuffer << data[0...ix+1]
|
404
|
+
ln = parser.linebuffer.join
|
405
|
+
parser.linebuffer.clear
|
406
|
+
log "[#{parser.state}]: #{ln}" if $DEBUG
|
407
|
+
if ln =~ HttpRequestRE
|
408
|
+
request_backlog << self.class.request_klass.new
|
409
|
+
parsing_request.connection = self
|
410
|
+
method, resource_uri, http_version = parse_init_line(ln)
|
411
|
+
parsing_request.method = method
|
412
|
+
parsing_request.resource_uri = resource_uri
|
413
|
+
parsing_request.query_params = parse_query_string(resource_uri.index('?') ? resource_uri[(resource_uri.index('?')+1)..-1] : '')
|
414
|
+
parsing_request.http_version = http_version
|
415
|
+
upon_new_request(parsing_request) if respond_to?(:upon_new_request)
|
416
|
+
parser.state = :headers
|
417
|
+
else
|
418
|
+
parser.bogus_line!(ln)
|
419
|
+
end
|
420
|
+
receive_data(data[(ix+1)..-1])
|
421
|
+
else
|
422
|
+
parser.linebuffer << data
|
423
|
+
end
|
424
|
+
when :headers
|
425
|
+
if ix = data.index("\n")
|
426
|
+
parser.linebuffer << data[0...ix+1]
|
427
|
+
ln = parser.linebuffer.join
|
428
|
+
parser.linebuffer.clear
|
429
|
+
# If it's a blank line, move to content state
|
430
|
+
if ln =~ BlankLineRE
|
431
|
+
upon_headers_finished(parsing_request) if respond_to?(:upon_headers_finished)
|
432
|
+
if parser.entity?
|
433
|
+
# evaluate_headers(parsing_request.headers)
|
434
|
+
parser.state = :entity
|
435
|
+
else
|
436
|
+
receive_full_request
|
437
|
+
end
|
438
|
+
else
|
439
|
+
header = parse_header_line(ln)
|
440
|
+
log "\t[#{parser.state}]: #{header.inspect}\n" if $DEBUG
|
441
|
+
receive_header(parsing_request, header.to_a[0]) if respond_to?(:receive_header)
|
442
|
+
parsing_request.headers.merge!(header)
|
443
|
+
end
|
444
|
+
receive_data(data[(ix+1)..-1])
|
445
|
+
else
|
446
|
+
parser.linebuffer << data
|
447
|
+
end
|
448
|
+
when :entity
|
449
|
+
if parser.entity_size
|
450
|
+
chars_yet_needed = parser.entity_size - parser.entity_pos
|
451
|
+
taking_this_many = [chars_yet_needed, data.length].sort.first
|
452
|
+
parser.textbuffer << data[0...taking_this_many]
|
453
|
+
leftover_data = data[taking_this_many..-1]
|
454
|
+
parser.entity_pos += taking_this_many
|
455
|
+
if parser.entity_pos >= parser.entity_size
|
456
|
+
entity_data = parser.textbuffer.join
|
457
|
+
parser.textbuffer.clear
|
458
|
+
parsing_request.entity << entity_data
|
459
|
+
log "[#{parser.state}]: #{entity_data}\n" if $DEBUG
|
460
|
+
receive_full_request
|
461
|
+
end
|
462
|
+
receive_data(leftover_data)
|
463
|
+
else
|
464
|
+
raise "TODO!"
|
465
|
+
# receive_binary_data data
|
466
|
+
end
|
467
|
+
|
468
|
+
else
|
469
|
+
# Probably shouldn't ever be here?
|
470
|
+
raise "Shouldn't be here!"
|
471
|
+
end
|
472
|
+
|
473
|
+
# TODO: Exception if number of parser.bogus_lines is higher than threshold
|
474
|
+
end
|
475
|
+
|
476
|
+
def process_request(request)
|
477
|
+
warn "STUB - overwrite process_request in a subclass of Http11Parser to process this #{request.inspect}"
|
478
|
+
end
|
479
|
+
|
480
|
+
def send_response!(http_response)
|
481
|
+
log "Sending Response: #{http_response.inspect}\n" if $DEBUG
|
482
|
+
socket.write(http_response)
|
483
|
+
request_backlog.shift
|
484
|
+
# Process the next request IF it is already waiting.
|
485
|
+
process_request(current_request) if current_request && current_request.ready?
|
486
|
+
end
|
487
|
+
|
488
|
+
private
|
489
|
+
def parse_init_line(ln)
|
490
|
+
method, resource_uri, http_version = ln.match(HttpRequestRE).to_a[1..-1]
|
491
|
+
# TODO: Exception if the request is improper!
|
492
|
+
[method, resource_uri, http_version]
|
493
|
+
end
|
494
|
+
|
495
|
+
def parse_query_string(qstring)
|
496
|
+
params = (CGI.parse(qstring) || {}) rescue {}
|
497
|
+
params.inject({}) do |h,(k,v)|
|
498
|
+
h[k.to_sym] = (v.is_a?(Array) && v.length == 1) ? v[0] : v
|
499
|
+
h
|
500
|
+
end
|
501
|
+
end
|
502
|
+
|
503
|
+
def parse_header_line(ln)
|
504
|
+
ln.chomp!
|
505
|
+
if ln =~ /:/
|
506
|
+
name,value = ln.split(/:\s*/,2)
|
507
|
+
if name.downcase == 'content-length'
|
508
|
+
parser.entity_size = Integer(value.gsub(/\D/,''))
|
509
|
+
# TODO: Exception if content-length specified is too big
|
510
|
+
end
|
511
|
+
{name.downcase => value}
|
512
|
+
else
|
513
|
+
parser.bogus_line!(ln)
|
514
|
+
{}
|
515
|
+
end
|
516
|
+
end
|
517
|
+
|
518
|
+
def receive_full_request
|
519
|
+
parsing_request.ready!
|
520
|
+
parser.state = :init
|
521
|
+
# Process this request now that we're ready -- IF any previous requests are already responded to.
|
522
|
+
# Otherwise, this request will be waiting, and when the previous one(s) are responded to, this one
|
523
|
+
# will be triggered next.
|
524
|
+
process_request(parsing_request) if parsing_request == current_request
|
525
|
+
log "Processed request. Prepared for next request.\n\n" if $DEBUG
|
526
|
+
parser.reset!
|
527
|
+
end
|
528
|
+
|
529
|
+
def parser
|
530
|
+
@parser ||= HeaderAndEntityStateStore.new(:init, "\n")
|
531
|
+
end
|
532
|
+
end
|
533
|
+
end
|
534
|
+
|
535
|
+
require 'iconv'
|
536
|
+
require 'rubygems'
|
537
|
+
require 'UniversalDetector'
|
538
|
+
require 'shared-mime-info'
|
539
|
+
class FileServer < EventParsers::Http11Parser::Request
|
540
|
+
# This is where the routing is processed.
|
541
|
+
def process
|
542
|
+
# Get the filename desired
|
543
|
+
filename = resource_uri.sub(/^\//,'')
|
544
|
+
if File.exists?(filename) && !File.directory?(filename)
|
545
|
+
content_type = MIME.check(filename).type
|
546
|
+
file_body = File.read(filename)
|
547
|
+
respond!('200 Ok', content_type, file_body)
|
548
|
+
else
|
549
|
+
respond!('400 Not Found', 'text/plain', "Could not find file: '#{resource_uri}'")
|
550
|
+
end
|
551
|
+
end
|
552
|
+
|
553
|
+
def respond!(status, content_type, body)
|
554
|
+
respond(status, content_type, body)
|
555
|
+
connection.halt!
|
556
|
+
end
|
557
|
+
def respond(status, content_type, body)
|
558
|
+
# Convert to UTF-8 if possible
|
559
|
+
chardet = UniversalDetector::chardet(body)
|
560
|
+
if chardet['confidence'] > 0.7
|
561
|
+
charset = chardet['encoding']
|
562
|
+
body = Iconv.conv('utf-8', charset, body)
|
563
|
+
# else # no conversion
|
564
|
+
end
|
565
|
+
body_length = body.length
|
566
|
+
|
567
|
+
# Send the response!
|
568
|
+
connection.send_response! "HTTP/1.1 #{status}\r\nServer: HTTP-Here, version #{$VERSION}\r\nContent-Type: #{content_type}\r\nContent-Length: #{body_length+2}\r\n\r\n#{body}\r\n"
|
569
|
+
span = (Time.now - connection.time).to_f
|
570
|
+
content_type
|
571
|
+
puts (status =~ /200/ ?
|
572
|
+
"Served #{resource_uri} (#{content_type})" :
|
573
|
+
"404 #{resource_uri}"
|
574
|
+
) + " at #{1 / span} requests/second"
|
575
|
+
end
|
576
|
+
end
|
577
|
+
|
578
|
+
# Requires that a module includes this and defines an initialize method that defines @options
|
579
|
+
class Http11Server
|
580
|
+
include EventParsers::Http11Parser
|
581
|
+
request_klass FileServer
|
582
|
+
|
583
|
+
attr_reader :time
|
584
|
+
def initialize(server,socket)
|
585
|
+
@socket = socket
|
586
|
+
@time = Time.now
|
587
|
+
end
|
588
|
+
|
589
|
+
# These just pass the call to the request to handle itself
|
590
|
+
def upon_new_request(request)
|
591
|
+
request.upon_new_request if request.respond_to?(:upon_new_request)
|
592
|
+
end
|
593
|
+
def upon_headers_finished(request)
|
594
|
+
request.upon_headers_finished if request.respond_to?(:upon_headers_finished)
|
595
|
+
end
|
596
|
+
def process_request(request)
|
597
|
+
request.process
|
598
|
+
end
|
599
|
+
|
600
|
+
def halt!
|
601
|
+
socket.close
|
602
|
+
throw :stop_reading
|
603
|
+
end
|
604
|
+
|
605
|
+
def upon_unbind
|
606
|
+
# puts "Client #{@socket} disconnected."
|
607
|
+
end
|
608
|
+
end
|
609
|
+
|
610
|
+
# EventMachineMini.ssl_config[:GenerateSSLCert] = true
|
611
|
+
|
612
|
+
puts "HTTP Here v#{$VERSION} : listening on #{$options[:address]}:#{$options[:port]}..."
|
613
|
+
begin
|
614
|
+
$server = EventMachineMini.new( :listen => {"#{$options[:address]}:#{$options[:port]}" => Http11Server} )
|
615
|
+
rescue Errno::EACCES
|
616
|
+
puts "\tLooks like you don't have permission to use that port!"
|
617
|
+
exit
|
618
|
+
end
|
619
|
+
|
620
|
+
Kernel.trap(:INT) { $server.stop! }
|
621
|
+
$server.run
|
622
|
+
|
623
|
+
puts " HTTP Here Finished\n"
|
624
|
+
exit
|
data/httphere.gemspec
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{httphere}
|
8
|
+
s.version = "1.0.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Daniel Parker"]
|
12
|
+
s.date = %q{2010-01-06}
|
13
|
+
s.default_executable = %q{httphere}
|
14
|
+
s.description = %q{httphere is a very small and simple ruby command-line HTTP file server.}
|
15
|
+
s.email = %q{gems@behindlogic.com}
|
16
|
+
s.executables = ["httphere"]
|
17
|
+
s.extra_rdoc_files = [
|
18
|
+
"LICENSE",
|
19
|
+
"README.rdoc"
|
20
|
+
]
|
21
|
+
s.files = [
|
22
|
+
".document",
|
23
|
+
".gitignore",
|
24
|
+
"LICENSE",
|
25
|
+
"README.rdoc",
|
26
|
+
"Rakefile",
|
27
|
+
"VERSION",
|
28
|
+
"bin/httphere",
|
29
|
+
"httphere.gemspec"
|
30
|
+
]
|
31
|
+
s.homepage = %q{http://dcparker.github.com/httphere}
|
32
|
+
s.post_install_message = %q{
|
33
|
+
[34mhttphere wants to detect MIME-types! For this
|
34
|
+
you'll need to install the open-source shared-mime-info.
|
35
|
+
Assuming you're on a Mac, you can simply run:
|
36
|
+
[0m [31msudo port install shared-mime-info[0m
|
37
|
+
|
38
|
+
* This gem has not been tested on Windows.
|
39
|
+
|
40
|
+
}
|
41
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
42
|
+
s.require_paths = ["lib"]
|
43
|
+
s.rubygems_version = %q{1.3.5}
|
44
|
+
s.summary = %q{A simple Ruby HTTP file server}
|
45
|
+
|
46
|
+
if s.respond_to? :specification_version then
|
47
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
48
|
+
s.specification_version = 3
|
49
|
+
|
50
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
51
|
+
s.add_runtime_dependency(%q<shared-mime-info>, [">= 0"])
|
52
|
+
s.add_runtime_dependency(%q<chardet>, [">= 0"])
|
53
|
+
else
|
54
|
+
s.add_dependency(%q<shared-mime-info>, [">= 0"])
|
55
|
+
s.add_dependency(%q<chardet>, [">= 0"])
|
56
|
+
end
|
57
|
+
else
|
58
|
+
s.add_dependency(%q<shared-mime-info>, [">= 0"])
|
59
|
+
s.add_dependency(%q<chardet>, [">= 0"])
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: httphere
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Daniel Parker
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2010-01-06 00:00:00 -05:00
|
13
|
+
default_executable: httphere
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: shared-mime-info
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: chardet
|
27
|
+
type: :runtime
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: "0"
|
34
|
+
version:
|
35
|
+
description: httphere is a very small and simple ruby command-line HTTP file server.
|
36
|
+
email: gems@behindlogic.com
|
37
|
+
executables:
|
38
|
+
- httphere
|
39
|
+
extensions: []
|
40
|
+
|
41
|
+
extra_rdoc_files:
|
42
|
+
- LICENSE
|
43
|
+
- README.rdoc
|
44
|
+
files:
|
45
|
+
- .document
|
46
|
+
- .gitignore
|
47
|
+
- LICENSE
|
48
|
+
- README.rdoc
|
49
|
+
- Rakefile
|
50
|
+
- VERSION
|
51
|
+
- bin/httphere
|
52
|
+
- httphere.gemspec
|
53
|
+
has_rdoc: true
|
54
|
+
homepage: http://dcparker.github.com/httphere
|
55
|
+
licenses: []
|
56
|
+
|
57
|
+
post_install_message: "\n\
|
58
|
+
\e[34mhttphere wants to detect MIME-types! For this\n\
|
59
|
+
you'll need to install the open-source shared-mime-info.\n\
|
60
|
+
Assuming you're on a Mac, you can simply run:\n\
|
61
|
+
\e[0m \e[31msudo port install shared-mime-info\e[0m\n\n * This gem has not been tested on Windows.\n\n"
|
62
|
+
rdoc_options:
|
63
|
+
- --charset=UTF-8
|
64
|
+
require_paths:
|
65
|
+
- lib
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: "0"
|
71
|
+
version:
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: "0"
|
77
|
+
version:
|
78
|
+
requirements: []
|
79
|
+
|
80
|
+
rubyforge_project:
|
81
|
+
rubygems_version: 1.3.5
|
82
|
+
signing_key:
|
83
|
+
specification_version: 3
|
84
|
+
summary: A simple Ruby HTTP file server
|
85
|
+
test_files: []
|
86
|
+
|