iodine 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of iodine might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 9451f837296bd4fecf60df42fd3cfee80d80ef0f
4
- data.tar.gz: af0d8bf95137d229b23ee74aef4825f50a9aafa0
3
+ metadata.gz: 08fe1f8f8ebb9d46760123f8ca97f9811e350190
4
+ data.tar.gz: bccdc5e606be4dcb98a27f81b39f115d6ee3c37e
5
5
  SHA512:
6
- metadata.gz: 6039e844f9b6658326a70fbadc12ebd61007b466a546100670382ce119d2d0bbf193497fc2c40a4fc36945352ad94e2f83f3a1c4f5f1a6a3c88f6c0719bf3dd1
7
- data.tar.gz: 608df34266557cb59300dd08321c3dcef74a67c7f7f0eec343ae77ac823f440d2108b40008e8c744fd753199ca73b0dffdfb96fc9bad4a2ce1aaac362319d093
6
+ metadata.gz: b203fafc5965ae514600798dac5ac96ed61f9a54e5ef55b32bad1335c79844dbf1a967e0603530f68d48fadd919c4f4d81257917cdea0934c9d8ee9232573186
7
+ data.tar.gz: cb90e413302e828682192bc349c2cadcd1e817b9b3a5c3cb41139153deddcd08ea259e561d6b8c75a30742afb4536b9d74bf41e0ecf27f57b35c970ac0e9e7c7
@@ -0,0 +1,48 @@
1
+ # Iodine
2
+ [![Gem Version](https://badge.fury.io/rb/iodine.svg)](https://badge.fury.io/rb/iodine)
3
+ [![Inline docs](http://inch-ci.org/github/boazsegev/iodine.svg?branch=master)](http://www.rubydoc.info/github/boazsegev/iodine/master/frames)
4
+
5
+ Please notice that this change log contains changes for upcoming releases as well. Please refer to the current gem version to review the current release.
6
+
7
+ ## Changes:
8
+
9
+ ***
10
+
11
+ Change log v.0.1.1
12
+
13
+ **Fix**: Fixed an issue where slow processing of Http/1 requests could cause timeout disconnections to occur while the request is being processed.
14
+
15
+ **Change/Security**: Uploads now use temporary files. Aceessing the data for file uploads should be done throught the `:file` property of the params hash (i.e. `params[:upload_field_name][:file]`). Using the `:data` property (old API) would cause the whole file to be dumped to the memory and the file's content will be returned as a String.
16
+
17
+ **Change/Security**: Http upload limits are now enforced. The current default limit is about ~0.5GB.
18
+
19
+ **Feature**: WebsocketClient now supports both an auto-connection-renewal and a polling machanism built in to the `WebsocketClient.connect` API. The polling feature is mostly a handy helper for testing, as it is assumed that connection renewal and pub/sub offer a better design than polling.
20
+
21
+ **Logging**: Better Http error logging and recognition.
22
+
23
+ ***
24
+
25
+ Change log v.0.1.0
26
+
27
+ **First actual release**:
28
+
29
+ We learn, we evolve, we change... but we remember our past and do our best to help with the transition and make it worth the toll it takes on our resources.
30
+
31
+ I took much of the code used for GRHttp and GReactor, changed it, morphed it and united it into the singular Iodine gem. This includes Major API changes, refactoring of code, bug fixes and changes to the core approach of how a task/io based application should behave or be constructed.
32
+
33
+ For example, Iodine kicks in automatically when the setup script is done, so that all code is run from within tasks and IO connections and no code is run in parallel to the Iodine engine.
34
+
35
+ Another example, Iodine now favors Object Oriented code, so that some actions - such as writing a network service - require classes of objects to be declared or inherited (i.e. the Protocol class).
36
+
37
+ This allows objects to manage their data as if they were in a single thread environment, unless the objects themselves are calling asynchronous code. For example, the Protocol class makes sure that the `on_open` and `on_message(data)` callbacks are excecuted within a Mutex (`on_close` is an exception to the rule since it is assumed that objects should be prepared to loose network connection at any moment).
38
+
39
+ Another example is that real-life deployemnt preferences were favored over adjustability or features. This means that some command-line arguments are automatically recognized (such as the `-p <port>` argument) and thet Iodine assumes a single web service per script/process (whereas GReactor and GRHttp allowed multiple listening sockets).
40
+
41
+ I tested this new gem during the 0.0.x version releases, and I feel that version 0.1.0 is stable enough to work with. For instance, I left the Iodine server running all night under stress (repeatedly benchmarking it)... millions of requests later, under heavey load, a restart wasn't required and memory consumption didn't show any increase after the warmup period.
42
+
43
+
44
+
45
+ ## License
46
+
47
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
48
+
data/README.md CHANGED
@@ -127,6 +127,24 @@ end
127
127
  Iodine::Http.on_websocket WSChatServer
128
128
  ```
129
129
 
130
+ ### Security and limits
131
+
132
+ Nobody wants their server to crash... Security measures are a fact of life as an internet entety. It is not only the theoretical malicious attacker from which a server must protect itself, but also from the unaware user or client.
133
+
134
+ Mostly, it is assumed that Iodine will run behind a proxy (i.e. within a Heroku Dyno or viaduct.io process), as such it is assumed that the proxy will protect the Iodine Http server from undue stress.
135
+
136
+ Having said that, Iodine is built with certain security measures in mind:
137
+
138
+ - Iodine will not accept IO data (neither from new connections nor form existing ones) while still answering existing requests and performing tasks. This safeguards against task overloading and DoS attacks causing a global crash, allowing the server to resume normal operation once a DoS attack had run it's course (and potentially allowing legitimate requests to be answered while the attack is still underway).
139
+
140
+ - Iodine will limit the query length (Http/1), header count and header data size as well as well as react to header overloading by immediate disconnections. Iodine's limits are hardcoded to be slightly more than double those of the common Proxies, so this counter-measure will only take effect should an attacker manage to bypass the Proxy.
141
+
142
+ - Iodine limits every Http request body-size (file upload data, form data, etc') to ~0.5GB. This setting can be changed using `Iodine::Http.max_http_buffer`. This safeguard is meant to prevent Ruby from crashing due to insufficient memory (an error Iodine cannot, and should not, recover from).
143
+
144
+ It is recommended that this number will be lowered substantially whenever possible, by using `Iodine::Http.max_http_buffer = new_value`
145
+
146
+ Do be aware that, at the moment, file uploads are passed through the memory when parsed. The parser's memory consumption will hopefully decrese in future releases, however, it is always recomended that large data be avoided when possible or handled using download/upload management protocols and services.
147
+
130
148
  ## Server Usage: Plug in your network protocol
131
149
 
132
150
  Iodine is designed to help write network services (Servers) where each script is intended to implement a single server.
@@ -17,7 +17,7 @@ require "iodine/http"
17
17
 
18
18
  # Iodine.processes = 4
19
19
 
20
- Iodine.protocol.on_http { "Hello World!" }
20
+ Iodine::Http.on_http { "Hello World!" }
21
21
 
22
22
 
23
23
  class WSChatServer < Iodine::Http::WebsocketHandler
@@ -46,7 +46,7 @@ Process.fork do
46
46
 
47
47
  Iodine.ssl = true
48
48
  Iodine.port = 3030
49
- Iodine.protocol.on_http do |req, res|
49
+ Iodine::Http.on_http do |req, res|
50
50
  res.session[:count] ||= 0
51
51
  res.session[:count] += 1
52
52
  res['content-type'] = 'text/plain'
@@ -7,6 +7,7 @@ require 'uri'
7
7
  require 'tmpdir'
8
8
  require 'zlib'
9
9
  require 'securerandom'
10
+ require 'tempfile.rb'
10
11
 
11
12
  require 'iodine/http/request'
12
13
  require 'iodine/http/response'
@@ -94,11 +95,20 @@ module Iodine
94
95
  def session_token= token
95
96
  @session_token = token
96
97
  end
97
- # Sets the session token for the Http server (String). Defaults to the name of the script.
98
+ # Gets the session token for the Http server (String). Defaults to the name of the script.
98
99
  def session_token
99
100
  @session_token
100
101
  end
101
102
 
103
+ # Sets the maximum bytes allowed for an HTTP's body request (file upload data). Defaults to the command line `-limit` argument, or ~0.25GB.
104
+ def max_http_buffer= size
105
+ @max_http_buffer = size
106
+ end
107
+ # Gets the maximum bytes allowed for an HTTP's body request (file upload data). Defaults to the command line `-limit` argument, or ~0.25GB.
108
+ def max_http_buffer
109
+ @max_http_buffer
110
+ end
111
+
102
112
  # Sets whether Iodine will allow connections to the experiemntal Http2 protocol. Defaults to false unless the `http2` command line flag is present.
103
113
  def http2= allow
104
114
  @http2 = allow && true
@@ -149,6 +159,7 @@ module Iodine
149
159
  protected
150
160
 
151
161
  @http2 = (ARGV.index('http2') && true)
162
+ @max_http_buffer = ((ARGV.index('-limit') && ARGV[ARGV.index('-limit') + 1]) || 536_870_912).to_i
152
163
 
153
164
  @websocket_app = @http_app = NOT_IMPLEMENTED = Proc.new { |i,o| false }
154
165
  @session_token = "#{File.basename($0, '.*')}_uuid"
@@ -16,9 +16,14 @@ module Iodine
16
16
  request = (@request ||= ::Iodine::Http::Request.new(self))
17
17
  unless request[:method]
18
18
  l = data.gets.strip
19
+ if l.bytesize > 16_384
20
+ write "HTTP/1.0 414 Request-URI Too Long\r\ncontent-length: 20\r\n\r\nRequest URI too Long"
21
+ Iodine.warn "Http/1 URI too long, closing connection."
22
+ return close
23
+ end
19
24
  next if l.empty?
20
25
  request[:method], request[:query], request[:version] = l.split(/[\s]+/, 3)
21
- return (Iodine.warn('Protocol Error, closing connection.') && close) unless request[:method] =~ HTTP_METHODS_REGEXP
26
+ return (Iodine.warn('Htt1 Protocol Error, closing connection.') && close) unless request[:method] =~ HTTP_METHODS_REGEXP
22
27
  request[:version] = (request[:version] || '1.1'.freeze).match(/[\d\.]+/)[0]
23
28
  request[:time_recieved] = Time.now
24
29
  end
@@ -27,6 +32,8 @@ module Iodine
27
32
  # n = l.slice!(0, l.index(':')); l.slice! 0
28
33
  # n.strip! ; n.downcase!; n.freeze
29
34
  # request[n] ? (request[n].is_a?(Array) ? (request[n] << l) : request[n] = [request[n], l ]) : (request[n] = l)
35
+ request[:headers_size] ||= 0
36
+ request[:headers_size] += l.bytesize
30
37
  l = l.strip.split(/:[\s]?/, 2)
31
38
  l[0].strip! ; l[0].downcase!;
32
39
  request[l[0]] ? (request[l[0]].is_a?(Array) ? (request[l[0]] << l[1]) : request[l[0]] = [request[l[0]], l[1] ]) : (request[l[0]] = l[1])
@@ -37,6 +44,10 @@ module Iodine
37
44
  Iodine.warn 'Protocol Error, closing connection.'
38
45
  return close
39
46
  end
47
+ if request.length > 2096 || request[:headers_size] > 262_144
48
+ write "HTTP/1.0 431 Request Header Fields Too Large\r\ncontent-length: 31\r\n\r\nRequest Header Fields Too Large"
49
+ return (Iodine.warn('Http1 header overloading, closing connection.') && close)
50
+ end
40
51
  end
41
52
  until request[:body_complete] && request[:headers_complete]
42
53
  if request['transfer-coding'.freeze] == 'chunked'.freeze
@@ -48,7 +59,7 @@ module Iodine
48
59
  return (Iodine.warn('Protocol Error, closing connection.') && close) unless @parser[:length]
49
60
  request[:body_complete] = true && break if @parser[:length] == 0
50
61
  @parser[:act_length] = 0
51
- request[:body] ||= ''
62
+ request[:body] ||= Tempfile.new('iodine'.freeze, :encoding => 'binary'.freeze)
52
63
  end
53
64
  chunk = data.read(@parser[:length] - @parser[:act_length])
54
65
  return false unless chunk
@@ -56,21 +67,27 @@ module Iodine
56
67
  @parser[:act_length] += chunk.bytesize
57
68
  (@parser[:act_length] = @parser[:length] = 0) && (data.gets) if @parser[:act_length] >= @parser[:length]
58
69
  elsif request['content-length'.freeze] && request['content-length'.freeze].to_i != 0
59
- request[:body] ||= ''
60
- packet = data.read(request['content-length'.freeze].to_i - request[:body].bytesize)
70
+ request[:body] ||= Tempfile.new('iodine'.freeze, :encoding => 'binary'.freeze)
71
+ packet = data.read(request['content-length'.freeze].to_i - request[:body].size)
61
72
  return false unless packet
62
73
  request[:body] << packet
63
- request[:body_complete] = true if request['content-length'.freeze].to_i - request[:body].bytesize <= 0
74
+ request[:body_complete] = true if request['content-length'.freeze].to_i - request[:body].size <= 0
64
75
  elsif request['content-type'.freeze]
65
76
  Iodine.warn 'Body type protocol error.' unless request[:body]
66
77
  line = data.gets
67
78
  return false unless line
68
- (request[:body] ||= '') << line
79
+ (request[:body] ||= Tempfile.new('iodine'.freeze, :encoding => 'binary'.freeze) ) << line
69
80
  request[:body_complete] = true if line =~ EOHEADERS
70
81
  else
71
82
  request[:body_complete] = true
72
83
  end
73
84
  end
85
+ if request[:body] && request[:body].size > ::Iodine::Http.max_http_buffer
86
+ Iodine.warn("Http1 message body too big, closing connection (Iodine::Http.max_http_buffer == #{::Iodine::Http.max_http_buffer} bytes) - #{request[:body].size} bytes.")
87
+ request.delete(:body).tap {|f| f.close unless f.closed? } rescue false
88
+ write "HTTP/1.0 413 Payload Too Large\r\ncontent-length: 17\r\n\r\nPayload Too Large"
89
+ return close
90
+ end
74
91
  (@request = ::Iodine::Http::Request.new(self)) && ( (::Iodine::Http.http2 && ::Iodine::Http::Http2.handshake(request, self, data)) || dispatch(request, data) ) if request.delete :body_complete
75
92
  end
76
93
  end
@@ -95,7 +112,12 @@ module Iodine
95
112
 
96
113
  send_headers response
97
114
  return log_finished(response) if request.head?
98
- (response.bytes_written += (write(body) || 0)) && (body.frozen? || body.clear) if body
115
+ if body
116
+ written = write(body)
117
+ return Iodine.warn "Http/1 couldn't send response because connection was lost." unless written
118
+ response.bytes_written += written
119
+ (body.frozen? || body.clear)
120
+ end
99
121
  close unless keep_alive
100
122
  log_finished response
101
123
  end
@@ -108,9 +130,13 @@ module Iodine
108
130
  end
109
131
  return if response.request.head?
110
132
  body = response.extract_body
111
- response.bytes_written += stream_data(body) if body || finish
133
+ if body
134
+ written = stream_data(body)
135
+ return Iodine.warn "Http/1 couldn't send response because connection was lost." unless written
136
+ response.bytes_written += written
137
+ end
112
138
  if finish
113
- response.bytes_written += stream_data('') unless body.nil?
139
+ response.bytes_written += stream_data('')
114
140
  log_finished response
115
141
  close unless response.keep_alive
116
142
  end
@@ -183,7 +209,7 @@ module Iodine
183
209
  response.raw_cookies.freeze
184
210
  end
185
211
  def stream_data data = nil
186
- write("#{data.to_s.bytesize.to_s(16)}\r\n#{data.to_s}\r\n") || 0
212
+ write("#{data.to_s.bytesize.to_s(16)}\r\n#{data.to_s}\r\n")
187
213
  end
188
214
 
189
215
  def log_finished response
@@ -290,9 +290,15 @@ module Iodine
290
290
 
291
291
  @header_buffer << frame[:body]
292
292
 
293
+ frame[:stream][:headers_size] ||= 0
294
+ frame[:stream][:headers_size] += frame[:body].bytesize
295
+
296
+ return (Iodine.warn('Http2 header overloading, closing connection.') && connection_error( ENHANCE_YOUR_CALM ) ) if frame[:stream][:headers_size] >  262_144
297
+
293
298
  return unless frame[:flags][2] == 1 # fin
294
299
 
295
300
  frame[:stream].update @hpack.decode(@header_buffer) # this is where HPACK comes in
301
+ return (Iodine.warn('Http2 header overloading, closing connection.') && connection_error( ENHANCE_YOUR_CALM ) ) if frame[:stream].length > 2096
296
302
  frame[:stream][:time_recieved] ||= Time.now
297
303
  frame[:stream][:version] ||= '2'.freeze
298
304
 
@@ -312,7 +318,13 @@ module Iodine
312
318
  frame[:body] = frame[:body][1...(0 - frame[:body][0].ord)]
313
319
  end
314
320
 
315
- frame[:stream][:body] ? (frame[:stream][:body] << frame[:body]) : (frame[:stream][:body] = frame[:body])
321
+ (frame[:stream][:body] ||= Tempfile.new('iodine'.freeze, :encoding => 'binary'.freeze) ) << frame[:body]
322
+
323
+ # check request size
324
+ if frame[:stream][:body].size > ::Iodine::Http.max_http_buffer
325
+ Iodine.warn("Http2 payload (message size) too big (Iodine::Http.max_http_buffer == #{::Iodine::Http.max_http_buffer} bytes) - #{frame[:stream][:body].size} bytes.")
326
+ return connection_error( ENHANCE_YOUR_CALM )
327
+ end
316
328
 
317
329
  process_request(@open_streams.delete frame[:sid]) if frame[:flags][0] == 1
318
330
  end
@@ -41,7 +41,7 @@ module Iodine
41
41
  # env.each {|k, v| env[k] = @request[v] if v.is_a?(Symbol)}
42
42
  RACK_ADDON.each {|k, v| env[k] = (request[v].is_a?(String) ? ( request[v].frozen? ? request[v].dup.force_encoding('ASCII-8BIT') : request[v].force_encoding('ASCII-8BIT') ): request[v])}
43
43
  request.each {|k, v| env["HTTP_#{k.upcase.tr('-', '_')}"] = v if k.is_a?(String) }
44
- env['rack.input'.freeze] ||= StringIO.new(''.force_encoding('ASCII-8BIT'.freeze))
44
+ env['rack.input'.freeze] ||= request[:body] || StringIO.new(''.force_encoding('ASCII-8BIT'.freeze))
45
45
  env['CONTENT_LENGTH'.freeze] = env.delete 'HTTP_CONTENT_LENGTH'.freeze if env['HTTP_CONTENT_LENGTH'.freeze]
46
46
  env['CONTENT_TYPE'.freeze] = env.delete 'HTTP_CONTENT_TYPE'.freeze if env['HTTP_CONTENT_TYPE'.freeze]
47
47
  env['HTTP_VERSION'.freeze] = "HTTP/#{request[:version].to_s}"
@@ -64,7 +64,7 @@ module Iodine
64
64
  # 'gr.cookies' => :cookies,
65
65
  'REQUEST_METHOD' => :method,
66
66
  'rack.url_scheme' => :scheme,
67
- 'rack.input' => :rack_input
67
+ 'rack.input' => :body
68
68
  }
69
69
 
70
70
  RACK_DICTIONARY = {
@@ -206,6 +206,9 @@ module Iodine
206
206
  # end
207
207
  # end
208
208
  return request if request[:client_ip]
209
+
210
+ request.delete :headers_size
211
+
209
212
  request[:client_ip] = request['x-forwarded-for'.freeze].to_s.split(/,[\s]?/)[0] || (request[:io].io.to_io.remote_address.ip_address) rescue 'unknown IP'.freeze
210
213
  request[:version] ||= '1'
211
214
 
@@ -262,7 +265,7 @@ module Iodine
262
265
  end
263
266
 
264
267
  # Adds paramaters to a Hash object, according to the Iodine's server conventions.
265
- def self.add_param_to_hash name, value, target
268
+ def self.add_param_to_hash name, value, target, &block
266
269
  begin
267
270
  c = target
268
271
  val = rubyfy! value
@@ -284,6 +287,7 @@ module Iodine
284
287
  else
285
288
  c[n] = val
286
289
  end
290
+ c.default_proc = block if block
287
291
  else
288
292
  if c[n]
289
293
  c[n].is_a?(Array) ? (c[n] << val) : (c[n] = [c[n], val])
@@ -340,80 +344,105 @@ module Iodine
340
344
  # read the body's data and parse any incoming data.
341
345
  def self.read_body request
342
346
  # save body for Rack, if applicable
343
- request[:rack_input] = StringIO.new(request[:body].dup.force_encoding(::Encoding::ASCII_8BIT)) if ::Iodine::Http.on_http == ::Iodine::Http::Rack
347
+ # request[:rack_input] = request[:body] if ::Iodine::Http.on_http == ::Iodine::Http::Rack
344
348
  # parse content
349
+ request[:body].rewind
345
350
  case request['content-type'.freeze].to_s
346
351
  when /x-www-form-urlencoded/
347
- extract_params request.delete(:body).split(/[&;]/), request[:params] #, :form # :uri
352
+ extract_params request[:body].read.split(/[&;]/), request[:params] #, :form # :uri
348
353
  when /multipart\/form-data/
349
- read_multipart request, request, request.delete(:body)
354
+ read_multipart request, request
350
355
  when /text\/xml/
351
356
  # to-do support xml?
352
- make_utf8! request[:body]
357
+ # request[:xml] = make_utf8! request[:body].read
353
358
  nil
354
359
  when /application\/json/
355
- JSON.parse(make_utf8! request[:body]).each {|k, v| add_param_to_hash k, v, request[:params]} rescue true
360
+ JSON.parse(make_utf8! request[:body].read).each {|k, v| add_param_to_hash k, v, request[:params]} rescue true
356
361
  end
362
+ request[:body].rewind if request[:body]
357
363
  end
358
364
 
359
365
  # parse a mime/multipart body or part.
360
- def self.read_multipart request, headers, part, name_prefix = ''
361
- if headers['content-type'].to_s =~ /multipart/i
362
- tmp = {}
363
- extract_header headers['content-type'].split(/[;,][\s]?/), tmp
364
- boundry = tmp[:boundary]
365
- if tmp[:name]
366
- if name_prefix.empty?
367
- name_prefix << tmp[:name]
368
- else
369
- name_prefix << "[#{tmp[:name]}]"
370
- end
366
+ def self.read_multipart request, headers = {}, boundary = [], name_prefix = ''
367
+ body = request[:body]
368
+ return unless headers['content-type'].to_s =~ /multipart/i
369
+ part_headers = {}
370
+ extract_header headers['content-type'].split(/[;,][\s]?/), part_headers
371
+ boundary << part_headers[:boundary]
372
+ if part_headers[:name]
373
+ if name_prefix.empty?
374
+ name_prefix << part_headers[:name]
375
+ else
376
+ name_prefix << "[#{part_headers[:name]}]"
377
+ end
378
+ end
379
+ part_headers.delete :name
380
+ part_headers.clear
381
+ line = nil
382
+ boundary_length = nil
383
+ true until ( (line = body.gets) ) && line =~ /\A--(#{boundary.join '|'})(--)?[\r]?\n/
384
+ until body.eof?
385
+ return if line =~ /--[\r]?\n/
386
+ return boundary.pop if boundary.count > 1 && line.match(/--(#{boundary.join '|'})/)[1] != boundary.last
387
+ boundary_length = line.bytesize
388
+ line = body.gets until line.nil? || line =~ /\:/
389
+ until line.nil? || line =~ /^[\r]?\n/
390
+ tmp = line.strip.split ':', 2
391
+ return Iodine.error "Http multipart parsing error (multipart header data malformed): #{line}" unless tmp && tmp.count == 2
392
+ tmp[0].strip!; tmp[0].downcase!; tmp[1].strip!;
393
+ part_headers[tmp[0]] = tmp[1]
394
+ line = body.gets
395
+ end
396
+ return if line.nil?
397
+ if !part_headers['content-disposition'.freeze]
398
+ Iodine.error "Wrong multipart format with headers: #{part_headers}"
399
+ return
400
+ end
401
+ extract_header part_headers['content-disposition'.freeze].split(/[;,][\s]?/), part_headers
402
+ if name_prefix.empty?
403
+ name = part_headers[:name][1..-2]
404
+ else
405
+ name = "#{name_prefix}[part_headers[:name][1..-2]}]"
371
406
  end
372
- part.split(/([\r]?\n)?--#{boundry}(--)?[\r]?\n/).each do |p|
373
- unless p.strip.empty? || p=='--'.freeze
374
- # read headers
375
- h = {}
376
- m = p.slice! /\A[^\r\n]*[\r]?\n/
377
- while m
378
- break if m =~ /\A[\r]?\n/
379
- m = m.match(/^([^:]+):[\s]?([^\r\n]+)/)
380
- h[m[1].downcase] = m[2] if m
381
- m = p.slice! /\A[^\r\n]*[\r]?\n/
407
+ part_headers.delete :name
408
+
409
+ start_part_pos = body.pos
410
+ tmp = /\A--(#{boundary.join '|'})(--)?[\r]?\n/
411
+ line.clear until ( (line = body.gets) && line =~ tmp)
412
+ end_part_pos = (body.pos - line.bytesize) - 2
413
+ new_part_pos = body.pos
414
+ body.pos = end_part_pos
415
+ end_part_pos += 1 unless body.getc == "\r"
416
+
417
+ if part_headers['content-type'.freeze]
418
+ if part_headers['content-type'.freeze] =~ /multipart/i
419
+ body.pos = start_part_pos
420
+ read_multipart request, part_headers, boundary, name_prefix
421
+ else
422
+ part_headers.delete 'content-disposition'.freeze
423
+ add_param_to_hash "#{name}[type]", make_utf8!(part_headers['content-type'.freeze]), request[:params]
424
+ part_headers.each {|k,v| add_param_to_hash "#{name}[#{k.to_s}]", make_utf8!(v[0] == '"' ? v[1..-2].to_s : v), request[:params] if v}
425
+
426
+ tmp = Tempfile.new 'upload', encoding: 'binary'
427
+ body.pos = start_part_pos
428
+ ((end_part_pos - start_part_pos)/65_536).to_i.times {tmp << body.read(65_536)}
429
+ tmp << body.read(end_part_pos - body.pos)
430
+ add_param_to_hash "#{name}[size]", tmp.size, request[:params]
431
+ add_param_to_hash "#{name}[file]", tmp, request[:params] do |hash, key|
432
+ if key == :data || key == "data" && hash.has_key?(:file) && hash[:file].is_a?(::Tempfile)
433
+ hash[:file].rewind
434
+ (hash[:data] = hash[:file].read)
435
+ end
382
436
  end
383
- # send headers and body to be read
384
- read_multipart request, h, p, name_prefix
437
+ tmp.rewind
385
438
  end
439
+ else
440
+ body.pos = start_part_pos
441
+ add_param_to_hash name, uri_decode!( body.read(end_part_pos - start_part_pos) ), request[:params]
386
442
  end
387
- return
388
- end
389
-
390
- # require a part body to exist (data exists) for parsing
391
- return true if part.to_s.empty?
392
-
393
- # convert part to `charset` if charset is defined?
394
-
395
- if !headers['content-disposition'.freeze]
396
- Iodine.error "Wrong multipart format with headers: #{headers} and body: #{part}"
397
- return
443
+ body.pos = new_part_pos
398
444
  end
399
445
 
400
- cd = {}
401
-
402
- extract_header headers['content-disposition'.freeze].split(/[;,][\s]?/), cd
403
-
404
- if name_prefix.empty?
405
- name = cd[:name][1..-2]
406
- else
407
- name = "#{name_prefix}[cd[:name][1..-2]}]"
408
- end
409
- if headers['content-type'.freeze]
410
- add_param_to_hash "#{name}[data]", part, request[:params]
411
- add_param_to_hash "#{name}[type]", make_utf8!(headers['content-type'.freeze]), request[:params]
412
- cd.each {|k,v| add_param_to_hash "#{name}[#{k.to_s}]", make_utf8!(v[1..-2].to_s), request[:params] unless k == :name || !v}
413
- else
414
- add_param_to_hash name, uri_decode!(part), request[:params]
415
- end
416
- true
417
446
  end
418
447
 
419
448
  end
@@ -238,6 +238,7 @@ module Iodine
238
238
  # attempts to write a non-streaming response to the IO. This can be done only once and will quitely fail subsequently.
239
239
  def finish
240
240
  request[:io].send_response self
241
+ request.delete(:body).tap {|f| f.close unless f.respond_to?(:close) && f.closed? rescue false } if request[:body] && @http_sblocks_count.to_i == 0
241
242
  end
242
243
 
243
244
  # Returns the connection's UUID.
@@ -347,9 +348,9 @@ module Iodine
347
348
  # response.cookies.set_response nil
348
349
  @flash.freeze
349
350
  end
350
- [].tap do |arr|
351
- @cookies.each {|k, v| arr << "#{k.to_s}=#{v.to_s}"}
352
- end
351
+ arr = []
352
+ @cookies.each {|k, v| arr << "#{k.to_s}=#{v.to_s}"}
353
+ arr
353
354
  end
354
355
 
355
356
  protected
@@ -373,6 +374,7 @@ module Iodine
373
374
  def finish_streaming
374
375
  return unless @http_sblocks_count == 0
375
376
  request[:io].stream_response self, true
377
+ request.delete(:body).tap {|f| f.close unless f.respond_to?(:close) && f.closed? rescue false } if request[:body]
376
378
  end
377
379
  end
378
380
  end
@@ -8,16 +8,17 @@ module Iodine
8
8
  # Use {Iodine::Http::WebsocketClient.connect} to initialize a client with all the callbacks needed.
9
9
  class WebsocketClient
10
10
 
11
- attr_accessor :response, :request
11
+ attr_accessor :response, :request, :params
12
12
 
13
13
  def initialize request
14
14
  @response = nil
15
15
  @request = request
16
- params = request[:ws_client_params]
17
- @on_message = params[:on_message]
16
+ @params = request[:ws_client_params]
17
+ @on_message = @params[:on_message]
18
18
  raise "Websocket client must have an #on_message Proc or handler." unless @on_message && @on_message.respond_to?(:call)
19
- @on_open = params[:on_open]
20
- @on_close = params[:on_close]
19
+ @on_open = @params[:on_open]
20
+ @on_close = @params[:on_close]
21
+ @renew = @params[:renew].to_i
21
22
  end
22
23
 
23
24
  def on event_name, &block
@@ -41,16 +42,49 @@ module Iodine
41
42
  instance_exec( data, &@on_message)
42
43
  end
43
44
 
44
- def on_open(&block)
45
- raise 'The on_open even is invalid at this point.' if block
45
+ def on_open
46
+ raise 'The on_open even is invalid at this point.' if block_given?
46
47
  @io = @request[:io]
47
48
  Iodine::Http::Request.parse @request
48
49
  instance_exec(&@on_open) if @on_open
50
+ if request[:ws_client_params][:every] && @params[:send]
51
+ raise TypeError, "Websocket Client `:send` should be either a String or a Proc object." unless @params[:send].is_a?(String) || @params[:send].is_a?(Proc)
52
+ Iodine.run_every @params[:every], self, @params do |ws, client_params, timer|
53
+ if ws.closed?
54
+ timer.stop!
55
+ next
56
+ end
57
+ if client_params[:send].is_a?(String)
58
+ ws.write client_params[:send]
59
+ elsif client_params[:send].is_a?(Proc)
60
+ ws.instance_exec(&client_params[:send])
61
+ end
62
+ end
63
+ end
49
64
  end
50
65
 
51
66
  def on_close(&block)
52
- @on_close = block if block
53
- instance_exec(&@on_close) if @on_close
67
+ return @on_close = block if block
68
+ if @renew > 0
69
+ renew_proc = Proc.new do
70
+ begin
71
+ Iodine::Http::WebsocketClient.connect(@params[:url], @params)
72
+ rescue
73
+ @renew -= 1
74
+ if @renew <= 0
75
+ Iodine.fatal "WebsocketClient renewal FAILED for #{@params[:url]}"
76
+ instance_exec(&@on_close) if @on_close
77
+ else
78
+ Iodine.run_after 2, &renew_proc
79
+ Iodine.warn "WebsocketClient renewal failed for #{@params[:url]}, #{@renew} attempts left"
80
+ end
81
+ false
82
+ end
83
+ end
84
+ renew_proc.call
85
+ else
86
+ instance_exec(&@on_close) if @on_close
87
+ end
54
88
  end
55
89
 
56
90
  # Sends data through the socket. a shortcut for ws_client.response <<
@@ -96,13 +130,23 @@ module Iodine
96
130
  # Acceptable options are:
97
131
  # on_open:: the on_open callback. Must be an objects that answers `call(ws)`, usually a Proc.
98
132
  # on_message:: the on_message callback. Must be an objects that answers `call(ws)`, usually a Proc.
99
- # on_close:: the on_close callback. Must be an objects that answers `call(ws)`, usually a Proc.
133
+ # on_close:: the on_close callback - this will ONLY be called if the connection WASN'T renewed. Must be an objects that answers `call(ws)`, usually a Proc.
100
134
  # headers:: a Hash of custom HTTP headers to be sent with the request. Header data, including cookie headers, should be correctly encoded.
101
135
  # cookies:: a Hash of cookies to be sent with the request. cookie data will be encoded before being sent.
102
136
  # timeout:: the number of seconds to wait before the connection is established. Defaults to 5 seconds.
137
+ # every:: this option, together with `:send` and `:renew`, implements a polling websocket. :every is the number of seconds between each polling event. without `:send`, this option will be ignored. defaults to nil.
138
+ # send:: a String to be sent or a Proc to be performed each polling interval. This option, together with `:every` and `:renew`, implements a polling websocket. without `:every`, this option will be ignored. defaults to nil. If `:send` is a Proc, it will be executed within the context of the websocket client object, with acess to the websocket client's instance variables and methods.
139
+ # renew:: the number of times to attempt to renew the connection if the connection is terminated by the remote server. Attempts are made in 2 seconds interval. The default for a polling websocket is 5 attempts to renew. For all other clients, the default is 0 (no renewal).
103
140
  #
104
141
  # The method will block until the connection is established or until 5 seconds have passed (the timeout). The method will either return a WebsocketClient instance object or raise an exception it the connection was unsuccessful.
105
142
  #
143
+ # Use Iodine::Http.ws_connect for a non-blocking initialization.
144
+ #
145
+ # An #on_close callback will only be called if the connection isn't or cannot be renewed. If the connection is renewed,
146
+ # the #on_open callback will be called again for a new Websocket client instance - but the #on_close callback will NOT be called.
147
+ #
148
+ # Due to this design, the #on_open and #on_close methods should NOT be used for opening IO resources (i.e. file handles) nor for cleanup IF the `:renew` option is enabled.
149
+ #
106
150
  # An on_message Proc must be defined, or the method will fail.
107
151
  #
108
152
  # The on_message Proc can be defined using the optional block:
@@ -136,6 +180,8 @@ module Iodine
136
180
  options[:on_message] ||= block
137
181
  raise "No #on_message handler defined! please pass a block or define an #on_message handler!" unless options[:on_message]
138
182
  url = URI.parse(url) unless url.is_a?(URI)
183
+ options[:url] = url
184
+ options[:renew] ||= 5 if options[:every] && options[:send]
139
185
 
140
186
  ssl = url.scheme == "https" || url.scheme == "wss"
141
187
 
@@ -49,9 +49,9 @@ module Iodine
49
49
  end
50
50
 
51
51
  # This method is called whenever a timeout has occurred. Either implement a ping or close the connection.
52
- # The default implementation closes the connection.
52
+ # The default implementation closes the connection unless the protocol is still processing information received before timeout occurred.
53
53
  def ping
54
- close
54
+ close unless @locker.locked?
55
55
  end
56
56
 
57
57
  #############
@@ -98,6 +98,7 @@ module Iodine
98
98
  r
99
99
  end
100
100
  rescue => e
101
+ # Iodine.info e.message
101
102
  close
102
103
  end
103
104
  end
@@ -1,3 +1,3 @@
1
1
  module Iodine
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iodine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Boaz Segev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2015-10-23 00:00:00.000000000 Z
11
+ date: 2015-10-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -61,6 +61,7 @@ extra_rdoc_files: []
61
61
  files:
62
62
  - ".gitignore"
63
63
  - ".travis.yml"
64
+ - CHANGELOG.md
64
65
  - Gemfile
65
66
  - LICENSE.txt
66
67
  - README.md