diode 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
+ SHA256:
3
+ metadata.gz: b084a1183fbd2e06a1c0f4dec2907c3f815548747c84e92a96a7dfcf55b9b226
4
+ data.tar.gz: 67f3cb1edc3f30fb787bf1f4821c4927d6af9f2a69f49f24cf789e8124bf6b21
5
+ SHA512:
6
+ metadata.gz: c26da0160afddfbf021741d2897a935a2ce72e852ad64193325d33c5c7c136f6dc04881738dbe26c84d2808a8b4e9794638dfa24cf5b0c98a8a68372bacec5db
7
+ data.tar.gz: a80bebc9303af4eca74a1c19a2668bb7d4ff2e70db3098c332f536302ce2881b838d29e64d2578e3edfa43774f3f50f6db69507a14f484a9a7db84b06a6e8353
@@ -0,0 +1,226 @@
1
+ require 'uri'
2
+
3
+ module Diode
4
+
5
+ class RequestError < StandardError
6
+ attr_accessor(:code)
7
+ def initialize(code, message="")
8
+ @code = code
9
+ super(message)
10
+ end
11
+ def ==(e) # define equality for convenient testing
12
+ message == e.message
13
+ end
14
+ end
15
+
16
+ class SecurityError < StandardError
17
+ end
18
+
19
+ class Request
20
+
21
+ def self.mock(url)
22
+ u = URI(url)
23
+ msg = "GET #{u.path}#{u.query.nil? ? "" : "?"+u.query} HTTP/1.1\r\nHost: #{u.host}\r\nUser-Agent: MockDiode/1.0\r\n\r\n"
24
+ new(msg)
25
+ end
26
+
27
+ attr_accessor(:method, :version, :url, :path, :params, :headers, :cookies, :body, :fields, :env, :filters, :remote, :pattern)
28
+
29
+ def initialize(msg)
30
+ reqline, sep, msg = msg.partition("\r\n")
31
+ raise(Diode::RequestError.new(400)) if reqline.to_s.empty?
32
+ raise(Diode::RequestError.new(405)) unless reqline.start_with?("GET ") or reqline.start_with?("POST ")
33
+ raise(Diode::RequestError.new(400)) unless reqline.end_with?(" HTTP/1.0") or reqline.end_with?(" HTTP/1.1")
34
+ @method = reqline.start_with?("GET ") ? "GET" : "POST"
35
+ @version = reqline[-3..-1]
36
+ @url = reqline[(@method.size+1)..-10]
37
+ @path, _sep, @query = @url.partition("?")
38
+ @params = {}
39
+ @fields = {}
40
+ unless @query.nil?
41
+ @query.split("&").each{|pair|
42
+ name, value = pair.split("=")
43
+ next if name.to_s.empty?
44
+ @params[name] = url_decode(value)
45
+ }
46
+ end
47
+ return if msg.nil?
48
+ @headers = {}
49
+ begin
50
+ headerline, sep, msg = msg.partition("\r\n")
51
+ while not headerline.strip.empty?
52
+ key, value = headerline.strip.split(': ')
53
+ @headers[key] = value
54
+ headerline, sep, msg = msg.partition("\r\n")
55
+ end
56
+ rescue EOFError
57
+ # tolerate missing \r\n at end of request
58
+ end
59
+ @cookies = {}
60
+ if @headers.key?("Cookie")
61
+ @headers["Cookie"].split('; ').each { |c|
62
+ k, eq, v = c.partition("=")
63
+ @cookies[k] = v
64
+ }
65
+ end
66
+ @body = msg
67
+ @fields = {} # to store fields from JSON or XML body
68
+ @env = {} # application settings, added by Diode::Server
69
+ @filters = [] # list of filters, set by Diode::Server
70
+ @remote = nil # AddrInfo set by Diode::Server - useful for logging the source IP
71
+ @pattern = %r{^/} # set by Diode::Server - used by Diode::Static
72
+ end
73
+
74
+ # convenience method for extra info
75
+ def [](k)
76
+ @env[k]
77
+ end
78
+
79
+ # convenience method to store extra info on a request
80
+ def []=(k,v)
81
+ @env[k] = v
82
+ end
83
+
84
+ def url_decode(s)
85
+ s.to_s.b.tr('+', ' ').gsub(/\%([A-Za-z0-9]{2})/) {[$1].pack("H2")}.force_encoding(Encoding::UTF_8)
86
+ end
87
+
88
+ # Extract fields by reading xml body in a strict format: any single root element, zero or more direct children only,
89
+ # attributes are ignored. A hash is assembled using tagname of child as key and text of child as value.
90
+ # the root may have an "id" attribute which will be treated like a field (named "id")
91
+ # Anything else is ignored.
92
+ # For example:
93
+ # <anything id="13"><firstname>john</firstname><age>25</age></anything>
94
+ # becomes:
95
+ # Hash { "id" => 13, "firstname" => "john", "age" => 25 }
96
+ def hash_xml(xml=nil)
97
+ xml ||= @body.dup()
98
+ pos = xml.index("<") # find root open tag
99
+ raise(Diode::RequestError.new(400, "invalid xml has no open tag")) if pos.nil?
100
+ xml.slice!(0,pos+1) # discard anything before opening tag name
101
+ pos = xml.index(">")
102
+ rootelement = xml.slice!(0, pos) # we might have "root" or 'root recordid="12345"'
103
+ xml.slice!(0,1) # remove the closing bracket of root element
104
+ pos = rootelement.index(" ")
105
+ if pos.nil?
106
+ roottag = rootelement
107
+ else
108
+ roottag = rootelement.slice(0,pos)
109
+ rest = /id="([^"]+)"/.match(rootelement[pos..-1])
110
+ @fields["id"] = rest[1] unless rest.nil? or rest.size < 2
111
+ end
112
+ raise(Diode::RequestError.new(400, "invalid root open tag")) if roottag.nil? or /\A[a-z][a-z0-9]+\z/.match(roottag).nil?
113
+ ending = xml.slice!(/\<\/#{roottag}\>.*$/m)
114
+ raise(Diode::RequestError.new(400, "invalid root close tag")) if ending.nil? # discard everything after close
115
+ # now we have a list of items like: \t<tagname>value</tagname>\n or maybe <tagname />
116
+ until xml.empty?
117
+ # find a field tagname
118
+ pos = xml.index("<")
119
+ break if pos.nil?
120
+ xml.slice!(0,pos+1) # discard anything before opening tag name
121
+ pos = xml.index(">")
122
+ raise(Diode::RequestError.new(400, "invalid field open tag")) if pos.nil?
123
+ if pos >= 2 and xml[pos-1] == "/" # we have a self-closed tag eg. <first updated="true"/>
124
+ tagelement = xml.slice!(0, pos+1)[0..-3] # tagname plus maybe attributes
125
+ pos = tagelement.index(" ")
126
+ tagname = (pos.nil?) ? tagelement : tagelement.slice(0,pos) # ignore attributes on fields
127
+ raise(Diode::RequestError.new(400, "invalid field open tag")) if tagname.nil? or /\A[a-z][a-z0-9]+\z/.match(tagname).nil?
128
+ @fields[tagname] = ""
129
+ else # eg. <first updated="true" >some value </first>\n
130
+ tagelement = xml.slice!(0, pos)
131
+ pos = tagelement.index(" ")
132
+ tagname = (pos.nil?) ? tagelement : tagelement.slice(0,pos) # ignore attributes on fields
133
+ raise(Diode::RequestError.new(400, "invalid field open tag")) if tagname.nil? or /\A[a-z][a-z0-9]+\z/.match(tagname).nil?
134
+ raise(Diode::RequestError.new(400, "duplicate field is not permitted")) if @fields.key?(tagname)
135
+ xml.slice!(0,1) # remove closing bracket
136
+ pos = xml.index("</#{tagname}>") # demand strict syntax for closing tag
137
+ raise(Diode::RequestError.new(400, "no closing tag")) if pos.nil?
138
+ raise(Diode::RequestError.new(400, "field value too long")) unless pos < 2048 # no field values 2048 bytes or larger
139
+ value = xml.slice!(0,pos)
140
+ @fields[tagname] = value
141
+ xml.slice!(0, "</#{tagname}>".size)
142
+ end
143
+ end
144
+ end
145
+
146
+ # Break up a dataset into an array of records (chunks of xml that can be passed to hash_xml).
147
+ # We insist on dataset/record names for tags.
148
+ def dataset_records()
149
+ xml=@body.dup()
150
+ pos = xml.index("<dataset") # find root open tag
151
+ raise(Diode::RequestError.new(400, "invalid xml has no root tag")) if pos.nil?
152
+ xml.slice!(0,pos+8) # discard anything before opening tag name
153
+ return([]) if xml.strip.start_with?("/>")
154
+ pos = xml.index("total=")
155
+ xml.slice!(0,pos+7) # remove up to number of records
156
+ count = xml[/\d+/].to_i()
157
+ return([]) if count.zero?
158
+ xml.slice!(0, xml.index(">")+1) # remove rest of dataset open tag
159
+ records = xml.split("</record>")
160
+ records.pop() # remove the dataset close tag
161
+ raise(Diode::RequestError.new(400, "records do not match total")) unless records.size == count
162
+ records.collect!{ |r| r+"</record>" }
163
+ records
164
+ end
165
+
166
+ # throws a SecurityError if there are any additional parameters found not in the list
167
+ def no_extra_parameters(*list)
168
+ kill = @params.keys()
169
+ list.each { |param| kill.delete(param) }
170
+ raise(Diode::SecurityError, "extra parameters #{kill}") unless kill.empty?
171
+ end
172
+
173
+ # throws a SecurityError if there are any additional fields found not in the list
174
+ def no_extra_fields(*list)
175
+ kill = @fields.keys()
176
+ list.each { |k| kill.delete(k) }
177
+ raise(Diode::SecurityError, "extra fields #{kill}") unless kill.empty?
178
+ end
179
+
180
+ def multipart_boundary()
181
+ spec = "multipart/form-data; boundary="
182
+ contentType = @headers["Content-Type"]
183
+ if contentType.start_with?(spec)
184
+ return(contentType.chomp.sub(spec, "").force_encoding("utf-8"))
185
+ else
186
+ return ""
187
+ end
188
+ end
189
+
190
+ # parses a multipart/form-data POST body, using the given boundary separator
191
+ def hash_multipartform(body, boundary)
192
+ # cannot use split on possibly invalid UTF-8, but we can use partition()
193
+ preamble, _, rest = body.partition("--"+boundary)
194
+ raise("preamble before boundary preamble="+preamble.inspect()) unless preamble.empty?
195
+ raise("no multipart ending found, expected --\\r\\n at end") unless rest.end_with?(boundary+"--\r\n")
196
+ until rest == "--\r\n"
197
+ part, _, rest = rest.partition("--"+boundary)
198
+ spec, _, value = part.chomp.partition("\r\n\r\n")
199
+ spec =~ /; name="([^"]+)"/m
200
+ name = $1
201
+ spec =~ /; filename="([^"]+)"/m
202
+ filename = $1
203
+ spec =~ /Content-Type: ([^"]+)/m
204
+ mimetype = $1
205
+ if mimetype.nil?
206
+ @fields[name] = value.force_encoding("UTF-8")
207
+ else
208
+ @fields[name] = {"filename" => filename, "mimetype" => mimetype, "contents" => value}
209
+ end
210
+ end
211
+ @fields
212
+ end
213
+
214
+ # return the request as a raw HTTP string
215
+ def to_s()
216
+ @headers["Content-Length"] = @body.bytes.size() unless @body.empty?
217
+ msg = ["#{@method} #{@url} HTTP/1.1"]
218
+ @headers.keys.each { |k|
219
+ msg << "#{k}: #{@headers[k]}"
220
+ }
221
+ msg.join("\r\n") + "\r\n\r\n" + @body
222
+ end
223
+
224
+ end
225
+ end
226
+
@@ -0,0 +1,52 @@
1
+ require 'time'
2
+
3
+ module Diode
4
+
5
+ class Response
6
+
7
+ STATUS = {
8
+ 200 => "OK",
9
+ 400 => "Bad Request",
10
+ 403 => "Forbidden",
11
+ 404 => "Not Found",
12
+ 405 => "Method Not Allowed"
13
+ }
14
+
15
+ DEFAULT_HEADERS = {
16
+ "Content-Type" => "application/json",
17
+ "Date" => Time.now.rfc2822(),
18
+ "Cache-Control" => "no-store",
19
+ "Server" => "Diode/1.0",
20
+ "Connection" => "Keep-Alive"
21
+ }
22
+
23
+ # returns the html for a few status codes
24
+ def self.standard(code)
25
+ raise("code #{code} is not supported") unless STATUS.key?(code)
26
+ message = STATUS[code]
27
+ body = "<html><head><title>#{message}</title></head><body><h1>#{code} - #{message}</h1></body></html>\n"
28
+ h = DEFAULT_HEADERS.merge({"Content-Type" => "text/html"})
29
+ new(code, body, h)
30
+ end
31
+
32
+ attr_accessor(:code, :body, :headers)
33
+
34
+ def initialize(code, body="", headers={})
35
+ @code = code.to_i()
36
+ @body = body
37
+ @headers = DEFAULT_HEADERS.merge(headers)
38
+ end
39
+
40
+ # return the response as a raw HTTP string
41
+ def to_s()
42
+ @headers["Content-Length"] = @body.bytes.size() unless @body.empty?
43
+ msg = ["HTTP/1.1 #{@code} #{STATUS[@code]}"]
44
+ @headers.keys.each { |k|
45
+ msg << "#{k}: #{@headers[k]}"
46
+ }
47
+ msg.join("\r\n") + "\r\n\r\n" + @body
48
+ end
49
+
50
+ end
51
+ end
52
+
@@ -0,0 +1,112 @@
1
+ require 'async/io'
2
+ require 'time'
3
+ require 'json'
4
+ require 'diode/request'
5
+ require 'diode/response'
6
+
7
+ module Diode
8
+
9
+ class Server
10
+
11
+ attr_accessor(:env, :filters)
12
+
13
+ def initialize(port, routing=[], env={})
14
+ @port = port.to_i()
15
+ @routing = routing
16
+ @env = env
17
+ @endpoint = Async::IO::Endpoint.tcp("127.0.0.1", @port)
18
+ @filters = [self] # list of filters that requests pass through before dispatch to a servlet
19
+ end
20
+
21
+ def start()
22
+ validate_routing()
23
+ Async do |server|
24
+ Signal.trap("INT") {
25
+ server.stop()
26
+ }
27
+ @endpoint.accept do |client|
28
+ rawRequest = read_request(client)
29
+ begin
30
+ request = Diode::Request.new(rawRequest)
31
+ request.remote = client.io.remote_address # decorate with request source address
32
+ request.env = @env.dup() # copy environment into request
33
+ request.filters = @filters.dup
34
+ response = (request.filters.shift).serve(request)
35
+ rescue Diode::SecurityError, Diode::RequestError => e
36
+ response = Diode::Response.standard(e.code)
37
+ end
38
+ complete(client, response)
39
+ client.close_write()
40
+ end
41
+ end
42
+ rescue
43
+ # ignore errors such as bad file decriptor on shutdown
44
+ end
45
+
46
+ # check routing table is sane
47
+ def validate_routing()
48
+ newRouting = []
49
+ @routing.each { |pattern, klass, *args|
50
+ raise("invalid pattern='#{pattern}' in routing table") unless pattern.is_a?(Regexp)
51
+ begin
52
+ servletKlass = klass.split("::").inject(Object) { |o,c| o.const_get(c) }
53
+ newRouting << [pattern, servletKlass, args]
54
+ rescue NameError
55
+ raise("unrecognised class #{klass} found in routing table")
56
+ end
57
+ }
58
+ @routing = newRouting # optimised so we can instantiate the servlet quickly
59
+ end
60
+
61
+ def read_request(client)
62
+ message = client.recv(2048000)
63
+ unless message.empty?
64
+ unless message.index("Content-Length: ").nil? # handle large messages such as file uploads
65
+ start = message.index("Content-Length: ")
66
+ stop = message.index("\r\n", start)
67
+ len = message[start..stop].chomp.sub("Content-Length: ","").to_i
68
+ bodyStart = message.index("\r\n\r\n") + 3 # up to end of 4 bytes
69
+ byteStart = message[0..bodyStart].bytes.size
70
+ remaining = len - (message.bytes.size - byteStart) # we have already read some of the content
71
+ while remaining > 0
72
+ chunk = client.recv(2048000)
73
+ message = message + chunk
74
+ remaining = remaining - chunk.bytes.size
75
+ end
76
+ end
77
+ end
78
+ message
79
+ end
80
+
81
+ # handle a new request connection
82
+ def serve(request) # keep signature consistent with filters and servlets
83
+ pattern, klass, args = @routing.find{ |pattern, klass, args|
84
+ not pattern.match(request.path).nil?
85
+ }
86
+ raise(Diode::RequestError.new(404)) if klass.nil?
87
+ servlet = klass.new(*args)
88
+ request.pattern = pattern # provide the mount pattern to the request, useful for Diode::Static
89
+ response = servlet.serve(request)
90
+ end
91
+
92
+ def complete(conn, response) # send the response, make sure its all sent
93
+ begin
94
+ http = response.to_s
95
+ total = http.bytes.size()
96
+ sent = 0
97
+ while sent < total
98
+ msgsize = conn.send(http.byteslice(sent..-1), 0)
99
+ sent = sent + msgsize
100
+ end
101
+ rescue Errno::EPIPE # ignore, we're finished anyway
102
+ end
103
+ end
104
+
105
+ def url_encode(s)
106
+ s.b.gsub(/([^ a-zA-Z0-9_.-]+)/) { |m|
107
+ '%' + m.unpack('H2' * m.bytesize).join('%').upcase
108
+ }.tr(' ', '+').force_encoding(Encoding::UTF_8) # url-encoded message
109
+ end
110
+
111
+ end
112
+ end
@@ -0,0 +1,59 @@
1
+ require 'pathname'
2
+
3
+ class Static
4
+
5
+ def initialize(docRoot=".")
6
+ @root = Pathname.new(Pathname.new(docRoot).cleanpath()).realpath()
7
+ raise("Cannot serve static files from path #{@root}") unless @root.directory? and @root.readable?
8
+ end
9
+
10
+ def serve(request)
11
+ return Diode::Response.new(405, "Method not allowed", {"Content-type" => "text/plain"}) unless request.method == "GET"
12
+ path = Pathname.new(request.path).cleanpath.sub(request.pattern, "") # remove the leading portion matching the mount pattern
13
+ filepath = Pathname.new(File.expand_path(path, @root))
14
+ return Diode::Response.new(404, "<html><body>File not found</body></html>", {"Content-type" => "text/html"}) unless filepath.exist?
15
+ mimetype = @@mimetypes[filepath.extname[1..-1]] || "application/octet-stream"
16
+ return Diode::Response.new(200, IO.read(filepath.to_s).b, {"Content-Type"=>mimetype, "Cache-Control" => "no-cache"})
17
+ end
18
+
19
+ @@mimetypes = {
20
+ "avi" => "video/x-msvideo",
21
+ "avif" => "image/avif",
22
+ "css" => "text/css",
23
+ "gif" => "image/gif",
24
+ "htm" => "text/html",
25
+ "html" => "text/html",
26
+ "ico" => "image/x-icon",
27
+ "jpeg" => "image/jpeg",
28
+ "jpg" => "image/jpeg",
29
+ "js" => "application/javascript",
30
+ "json" => "application/json",
31
+ "mov" => "video/quicktime",
32
+ "mp4" => "video/mp4",
33
+ "mpe" => "video/mpeg",
34
+ "mpeg" => "video/mpeg",
35
+ "mpg" => "video/mpeg",
36
+ "otf" => "font/otf",
37
+ "pdf" => "application/pdf",
38
+ "png" => "image/png",
39
+ "qt" => "video/quicktime",
40
+ "rb" => "text/plain",
41
+ "svg" => "image/svg+xml",
42
+ "tif" => "image/tiff",
43
+ "tiff" => "image/tiff",
44
+ "ttc" => "font/collection",
45
+ "ttf" => "font/ttf",
46
+ "txt" => "text/plain",
47
+ "webm" => "video/webm",
48
+ "webp" => "image/webp",
49
+ "woff" => "font/woff",
50
+ "woff2" => "font/woff2",
51
+ "xhtml" => "text/html",
52
+ "xml" => "text/xml",
53
+ }
54
+
55
+ def self.mimetypes
56
+ @@mimetypes
57
+ end
58
+
59
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: diode
3
+ version: !ruby/object:Gem::Version
4
+ version: '1.0'
5
+ platform: ruby
6
+ authors:
7
+ - Kjell Koda
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-07-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: async-io
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: A simple hello world gem
28
+ email: kjell@null4.net
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - lib/diode/request.rb
34
+ - lib/diode/response.rb
35
+ - lib/diode/server.rb
36
+ - lib/diode/static.rb
37
+ homepage: https://github.com/kjellhex/diode
38
+ licenses:
39
+ - MIT
40
+ metadata: {}
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.4.10
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: A fast, simple, pure-ruby application server.
60
+ test_files: []