icaprb-server 0.0.1

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.
@@ -0,0 +1,295 @@
1
+ require 'uri'
2
+ module ICAPrb
3
+ module Server
4
+ # The Parser module contains methods to parse ICAP or HTTP messages.
5
+ module Parser
6
+
7
+ # This class indicates an error while parsing the request
8
+ class ICAP_Parse_Error < RuntimeError
9
+ end
10
+ # This class indicates an error while parsing the request
11
+ class HTTP_Parse_Error < RuntimeError
12
+ end
13
+
14
+ # This module contains the Methods included in the request parser as well as in the Service.
15
+ # It is used to provide parsing methods which are used to read the full request.
16
+ module ChunkedEncodingHelper
17
+ # this method reads a chunk from an Object, which supports the method "gets" and it is usually a socket.
18
+ # it will return the data sent over the socket and return it. It will also return the information,
19
+ # if the chunk had an ieof flag set. ieof means there is no data available and the server must not ask
20
+ # to continue.
21
+ #
22
+ # params:
23
+ # +io+:: socket
24
+ #
25
+ # returns:
26
+ # [string data, ieof_set]
27
+ private
28
+ def read_chunk(io)
29
+ str = "\r\n"
30
+ until (str = io.gets) != "\r\n"
31
+ end
32
+ total_length, ieof = read_chunk_length(str)
33
+ result = ''
34
+ until result.length >= total_length
35
+ result += io.gets
36
+ end
37
+ if total_length == 0
38
+ return :eof,ieof
39
+ end
40
+ # cut the protocol overhead
41
+ return result[0...total_length], ieof
42
+ end
43
+
44
+ # returns the length of the chunk as first argument and if ieof is set as second
45
+ private
46
+ def read_chunk_length(line)
47
+ x = line.scan(/\A([A-Fa-f0-9]+)(; ieof)?\r\n/).first
48
+ [x[0].to_i(16), !x[1].nil?]
49
+ end
50
+ end
51
+
52
+ # parse header line and returns the parsed line as Array which has the name on index 0 and the value on index 1
53
+ def parse_header(line)
54
+ # remove newlines
55
+ line = line.gsub!("\r",'')
56
+ line = line.gsub!("\n",'')
57
+ line.split(':',2).map {|x| x.strip}
58
+ end
59
+
60
+
61
+ end
62
+ # This class is used to parse ICAP requests. It includes the Parser module.
63
+ class ICAPRequestParser
64
+ include Parser
65
+
66
+ # initializes a new ICAP request parser
67
+ # params:
68
+ # +io+:: the Socket
69
+ def initialize(io)
70
+ @io = io
71
+ end
72
+
73
+ # This method parses the request line and returns an Hash with the components
74
+ # * icap_method:: can be :req_mod, :resp_mod or :options
75
+ # * uri:: an URI
76
+ # * version:: the used version of ICAP
77
+ # @raise ICAP_Parse_Error if something is invalid
78
+ def parse_icap_request_line(line)
79
+ str_method, str_uri,_,_,_, str_version = line.scan(/(REQMOD|RESPMOD|OPTIONS) (((icap[s]?:)?[^\s]+)|(\*)) ICAP\/([\d\.]+)/i).first
80
+ raise ICAP_Parse_Error.new "invalid icap Method in RequestLine #{line}" if str_method.nil?
81
+ case str_method.upcase
82
+ when 'REQMOD'
83
+ icap_method = :request_mod
84
+ when 'RESPMOD'
85
+ icap_method = :response_mod
86
+ when 'OPTIONS'
87
+ icap_method = :options
88
+ else
89
+ raise ICAP_Parse_Error.new 'The request type is not known'
90
+ end
91
+ uri = URI(str_uri)
92
+ unless icap_method && uri && str_version
93
+ raise ICAP_Parse_Error.new 'The request line is not complete.'
94
+ end
95
+ {icap_method: icap_method, uri: uri, version: str_version}
96
+ end
97
+
98
+ # parse all headers
99
+ def parse
100
+ line = "\r\n"
101
+ while line == "\r\n"
102
+ line = @io.gets
103
+ end
104
+ return nil unless line
105
+ icap_req_line = parse_icap_request_line(line)
106
+ icap_headers = {}
107
+ until (line = @io.gets) == "\r\n"
108
+ parsed_header = parse_header(line)
109
+ icap_headers[parsed_header[0]] = parsed_header[1]
110
+ end
111
+ {request_line: icap_req_line, header: icap_headers}
112
+ end
113
+ end
114
+
115
+ # parses HTTP Headers
116
+ class HTTPHeaderParser
117
+ include Parser
118
+
119
+ # initializes a new HTTPHeaderParser
120
+ # params:
121
+ # +io+:: the socket
122
+ # +is_request+:: value to say if it is an response or an request because the request line / status line
123
+ # look different
124
+ def initialize(io,is_request = true)
125
+ @io = io
126
+ @length_read = 0
127
+ @is_request = is_request
128
+ end
129
+ # This method parses the request line and returns an Hash with the components
130
+ # * http_method:: a string
131
+ # * uri:: an URI
132
+ # * version:: the used version of HTTP
133
+ # @raise HTTP_Parse_Error if something is invalid
134
+ def parse_http_request_line(line)
135
+ @length_read += line.length
136
+ str_method, str_uri, str_version = line.scan(/(GET|POST|PUT|DELETE|PATCH|OPTIONS|TRACE|HEAD|CONNECT) (\S+) HTTP\/([\d\.]+)/i).first
137
+ raise HTTP_Parse_Error.new 'invalid http Method' if str_method.nil?
138
+ uri = URI(str_uri)
139
+ unless str_method && uri && str_version
140
+ raise HTTP_Parse_Error.new 'The request line is not complete.'
141
+ end
142
+ {http_method: str_method, uri: uri, version: str_version}
143
+ end
144
+ # This method parses the response line and returns an Hash with the components
145
+ # * status:: an integer
146
+ # * version:: the used version of HTTP
147
+ # @raise HTTP_Parse_Error if something is invalid
148
+ def parse_http_response_line(line)
149
+ @length_read += line.length
150
+ str_version, str_code, _ = line.scan(/HTTP\/([\d\.]+) (\d+) ([A-Za-z0-9 \-]+)\r\n/i).first
151
+ raise HTTP_Parse_Error.new 'invalid Code' if str_code.nil?
152
+ code = str_code.to_i
153
+ unless code && str_version
154
+ raise HTTP_Parse_Error.new 'The request line is not complete.'
155
+ end
156
+ {code: code, version: str_version}
157
+ end
158
+
159
+ # parse all headers
160
+ def parse
161
+ if @is_request
162
+ http_req_line = parse_http_request_line(@io.gets)
163
+ header = RequestHeader.new(http_req_line[:http_method],http_req_line[:uri],http_req_line[:version])
164
+ else
165
+ http_response_line = parse_http_response_line(@io.gets)
166
+ header = ResponseHeader.new(http_response_line[:version],http_response_line[:code])
167
+ end
168
+ until (line = @io.gets) == "\r\n"
169
+ parsed_header = parse_header(line)
170
+ header[parsed_header[0]] = parsed_header[1]
171
+ end
172
+ header
173
+ end
174
+ end
175
+
176
+ # The request parser uses the ICAP and HTTP parsers to parse the complete request.
177
+ # It is the main parser used by the server which gets the socket to read the +ICAP+
178
+ # headers which are sent by the client.
179
+ # Depending on the headers and the values we got, we will decide, which other parsers
180
+ # we need and how to read it. the parsed request will be returned and depending on the
181
+ # data, the server will decide what it will do with the request.
182
+ class RequestParser
183
+
184
+ # create a new instance of a +RequestParser+
185
+ # params:
186
+ # +io+:: a socket to communicate
187
+ # +ip+:: the peer ip address
188
+ # +server+:: the instance of the server which uses the parser to get the service names.
189
+ def initialize(io,ip,server)
190
+ @io = io
191
+ @ip = ip
192
+ @server = server
193
+ end
194
+
195
+ # Parses the complete request and returns the parsed result.
196
+ # It will return the parsed result.
197
+ #
198
+ # if an +Encapsulated+ header is set, it will also parse the encapsulated +HTTP+ header and the body if
199
+ # available
200
+ def parse
201
+ # parse ICAP headers
202
+ icap_parser = ICAPRequestParser.new(@io)
203
+ icap_data = icap_parser.parse
204
+ return nil unless icap_data
205
+ if icap_data[:header]['Encapsulated']
206
+ encapsulation = icap_data[:header]['Encapsulated']
207
+ encapsulated_parts = encapsulation.split(',').map do |part|
208
+ part.split('=').map(&:strip)
209
+ end
210
+ else
211
+ encapsulated_parts = []
212
+ end
213
+ parsed_data = {icap_data: icap_data, encapsulated: encapsulated_parts}
214
+ parts = []
215
+ service_name = icap_data[:request_line][:uri].path
216
+ service_name = service_name[1...service_name.length]
217
+ service = @server.services[service_name]
218
+ if service
219
+ disable_preview = !service.supports_preview?
220
+ preview_size = service.preview_size
221
+ else
222
+ disable_preview = true
223
+ preview_size = nil
224
+ end
225
+ encapsulated_parts.each do |ep|
226
+ parts << case ep[0]
227
+ when 'null-body'
228
+ NullBody.new
229
+ when 'req-hdr'
230
+ http_parser = HTTPHeaderParser.new(@io,true)
231
+ parsed_data[:http_request_header] = http_parser.parse
232
+ when 'res-hdr'
233
+ http_parser = HTTPHeaderParser.new(@io,false)
234
+ parsed_data[:http_response_header] = http_parser.parse
235
+ when 'req-body'
236
+ bp = BodyParser.new(@io, disable_preview, (icap_data[:header]['Preview'] || nil))
237
+ p_data = bp.parse
238
+ parsed_data[:http_request_body] = RequestBody.new(p_data[0],p_data[1])
239
+ when 'res-body'
240
+ bp = BodyParser.new(@io, disable_preview, (icap_data[:header]['Preview'] || nil))
241
+ p_data = bp.parse
242
+ parsed_data[:http_response_body] = ResponseBody.new(p_data[0],p_data[1])
243
+ else
244
+ nil
245
+ end
246
+ end
247
+
248
+ parsed_data
249
+ end
250
+ end
251
+
252
+ # this class will parse an http body which has to be chunked encoded.
253
+ class BodyParser
254
+ # By default, it will try to receive all data if the service does not provide information,
255
+ # if it supports previews.
256
+ # params:
257
+ # +io+:: the Socket
258
+ # +read_everything+:: read all data before forwarding them to the service - true by default
259
+ # (set a preview size in the service to override)
260
+ # +preview_header+:: if a preview header is set, we can find out how long the preview will be.
261
+ # so we know how much data we can expect.
262
+ def initialize(io, read_everything = true, preview_header = nil)
263
+ @io = io
264
+ @read_everything = read_everything
265
+ if preview_header
266
+ @preview_size = preview_header.to_i
267
+ else
268
+ @preview_size = nil
269
+ end
270
+ end
271
+
272
+ # parses all chunks and concatenates then until:
273
+ # * the end of the preview is reached and the service is not correctly configured
274
+ # * die end of the data is reached
275
+ # it will return the data it got and if the ieof has been set.
276
+ def parse
277
+ data = ''
278
+ ieof = false
279
+ until (line,ieof = read_chunk(@io); line) && line == :eof
280
+ data += line
281
+ end
282
+ if !ieof && @read_everything && !@preview_size.nil? && (@preview_size >= data.length)
283
+ Response.continue(@io)
284
+ until (line,ieof2 = read_chunk(@io); line) && line == :eof
285
+ data += line
286
+ ieof ||= ieof2
287
+ end
288
+ end
289
+ return data, ieof
290
+ end
291
+
292
+ include ::ICAPrb::Server::Parser::ChunkedEncodingHelper
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,168 @@
1
+ require_relative './data_structures'
2
+ require_relative './constants'
3
+ require 'date'
4
+ require 'erb'
5
+ module ICAPrb
6
+ module Server
7
+ # The Response class creates a valid ICAP response to send over the socket.
8
+ class Response
9
+ # A +Hash+ containing the header of the ICAP response
10
+ attr_accessor :icap_header
11
+ # The ICAP Version - usually 1.0
12
+ attr_accessor :icap_version
13
+ # The ICAP status code like 200 for OK
14
+ attr_accessor :icap_status_code
15
+ # the parts of the ICAP response
16
+ attr_accessor :components
17
+ # creates a new instance of ICAPrb::Server::Response and initialises some headers
18
+ def initialize
19
+ # the ISTag is used to let the proxy know that the old response (probably cached)
20
+ # is invalid if this value changes
21
+ @icap_header = {'Date' => Time.now.gmtime, 'Server' => 'ICAP.rb', 'Connection' => 'Close', 'ISTag' => '"replace"'}
22
+ @icap_version = '1.0'
23
+ @icap_status_code = 200
24
+ @components = []
25
+ end
26
+
27
+ # creates the status line for the ICAP protocol
28
+ # Returns the status line as a +String+
29
+ def response_line
30
+ "ICAP/#{@icap_version} #{@icap_status_code} #{ICAP_STATUS_CODES[@icap_status_code]}\r\n"
31
+ end
32
+
33
+ # convert an hash of keys (header names) and values to a +String+
34
+ #
35
+ # Params:
36
+ # +value+:: the hash to convert
37
+ #
38
+ # Returns:: the ICAP headers as +String+
39
+ def hash_to_header
40
+ headers = []
41
+ # add Encapsulated Header if we have a body
42
+ encapsulated = encapsulated_header
43
+ value = @icap_header
44
+ value = @icap_header.merge(encapsulated) if encapsulated['Encapsulated'].length > 0
45
+ value.each do |key, value|
46
+ headers << "#{key}: #{value}"
47
+ end
48
+ headers.join("\r\n") + "\r\n\r\n"
49
+ end
50
+
51
+ # creates the encapsulated header from an array of components which it is for
52
+ # Params:
53
+ # +components+:: an array of the components of the ICAP response The components can be an instance of
54
+ # RequestHeader, RequestBody, ResponseHeader or ResponseBody
55
+ #
56
+ # Returns::
57
+ # A Hash containing only one entry with the key 'Encapsulated' which holds the offsets of the components
58
+ def encapsulated_header
59
+ encapsulated_hdr = 'Encapsulated'
60
+ encapsulated_hdr_list = []
61
+ offset = 0
62
+ @components.sort.each do |component|
63
+ case component
64
+ when RequestHeader
65
+ encapsulated_hdr_list << "req-hdr=#{offset}"
66
+ when ResponseHeader
67
+ encapsulated_hdr_list << "res-hdr=#{offset}"
68
+ when RequestBody
69
+ encapsulated_hdr_list << "req-body=#{offset}"
70
+ when ResponseBody
71
+ encapsulated_hdr_list << "res-body=#{offset}"
72
+ when NullBody
73
+ encapsulated_hdr_list << "null-body=#{offset}"
74
+ end
75
+ offset += component.to_s.length
76
+ end
77
+ {encapsulated_hdr => encapsulated_hdr_list.join(', ')}
78
+ end
79
+ # writes the headers into a string and returns them
80
+ # it raises an exception if the response would be incorrectly created (for example multiple headers)
81
+ # it will create the full ICAP + HTTP header (if available)
82
+ def write_headers
83
+ output = response_line
84
+ output += hash_to_header
85
+ s_comp = @components.sort
86
+
87
+ # add request header if it exists
88
+ request_header = s_comp.select {|component| component.class == RequestHeader}
89
+ raise 'The request header can be included only once' if request_header.count > 1
90
+ request_header.each do |rh|
91
+ output += rh.to_s
92
+ end
93
+
94
+ # add response header to the response if it exists
95
+ response_header = s_comp.select {|component| component.class == ResponseHeader}
96
+ raise 'The request header can be included only once' if response_header.count > 1
97
+ response_header.each do |rh|
98
+ output += rh.to_s
99
+ end
100
+ # return the output
101
+ output
102
+ end
103
+
104
+ # send headers to the client.
105
+ #
106
+ # Params:
107
+ # +io+:: Socket where the headers should be sent to
108
+ def write_headers_to_socket(io)
109
+ io.write write_headers
110
+ end
111
+ # basic template for a HTML error page
112
+ ERROR_TEMPLATE = ERB.new('<html><head><meta charset="utf-8" /><title>ICAP.rb<% unless params["title"].nil? %> ::'+
113
+ ' <%= params["title"] %><% end %></title></head>'+
114
+ '<body><h1><%= params["title"] || "Error" %></h1><div>'+
115
+ '<%= params["content"] || "Content missing" %></div></body></html>')
116
+ # display an error page when something is not ok
117
+ # this is a server function because it is also required for errors which cannot be caught by a service
118
+ #
119
+ # Params
120
+ # +io+:: The object, where the response should be written to
121
+ # +status+:: ICAP status code to send
122
+ # +params+:: parameters for the template and the response as well
123
+ def self.display_error_page(io,status, params)
124
+ response = Response.new
125
+ response.icap_status_code = status
126
+ http_resp_header = ResponseHeader.new(params[:http_version],params[:http_status])
127
+ http_resp_header['Content-Type'] = 'text/html; charset=utf-8'
128
+ http_resp_body = ResponseBody.new(ERROR_TEMPLATE.result(binding), false)
129
+ http_resp_header['Content-Length'] = http_resp_body.length
130
+ response.components << http_resp_header
131
+ response.components << http_resp_body
132
+ response.write_headers_to_socket io
133
+ io.write(http_resp_body.to_chunk)
134
+ send_last_chunk(io,false)
135
+ io.close
136
+ end
137
+
138
+ # this method is an alternative to display_error_page. It does not send any http information to the client.
139
+ # instead it will send an ICAP header which will indicate an error.
140
+ def self.error_response(io)
141
+ response = Response.new
142
+ response.icap_status_code = 500
143
+ response.components << NullBody.new
144
+ response.write_headers_to_socket io
145
+ end
146
+
147
+ # sends the information to the client, that it should send the rest of the file. This method does not keep
148
+ # track of your connection and if you call it twice, your client may have trouble with your response. Use
149
+ # it only once and only in +preview+ mode.
150
+ def self.continue(io,icap_version = '1.0')
151
+ io.write "ICAP/#{icap_version} 100 Continue\r\n\r\n"
152
+ end
153
+
154
+ # this method sends the last (empty) chunk and it will add the ieof marker if it is requested.
155
+ # this empty chunk is used to indicate the end of the body encoded in chunked encondig.
156
+ def self.send_last_chunk(io,in_preview = false)
157
+ data = '0'
158
+ if in_preview
159
+ data += '; ieof'
160
+ end
161
+ data += "\r\n\r\n"
162
+ io.write(data)
163
+ end
164
+
165
+ end
166
+ end
167
+ end
168
+