diode 1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ 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: []