]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/httputil.rb
httputil and url plugin improvements, see ChangeLog
[user/henk/code/ruby/rbot.git] / lib / rbot / core / utils / httputil.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot HTTP provider
5 #
6 # Author:: Tom Gilbert <tom@linuxbrit.co.uk>
7 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 #
9 # Copyright:: (C) 2002-2005 Tom Gilbert
10 # Copyright:: (C) 2006 Tom Gilbert, Giuseppe Bilotta
11 # Copyright:: (C) 2006,2007 Giuseppe Bilotta
12
13 require 'resolv'
14 require 'net/http'
15 begin
16   require 'net/https'
17 rescue LoadError => e
18   error "Couldn't load 'net/https':  #{e.inspect}"
19   error "Secured HTTP connections will fail"
20 end
21
22 module ::Net
23   class HTTPResponse
24     # Read chunks from the body until we have at least _size_ bytes, yielding
25     # the partial text at each chunk. Return the partial body.
26     def partial_body(size, &block)
27
28       partial = String.new
29
30       self.read_body { |chunk|
31         partial << chunk
32         yield partial
33         break if size and partial.length >= size
34       }
35
36       return partial
37     end
38   end
39 end
40
41 Net::HTTP.version_1_2
42
43 module ::Irc
44 module Utils
45
46 # class for making http requests easier (mainly for plugins to use)
47 # this class can check the bot proxy configuration to determine if a proxy
48 # needs to be used, which includes support for per-url proxy configuration.
49 class HttpUtil
50     BotConfig.register BotConfigBooleanValue.new('http.use_proxy',
51       :default => false, :desc => "should a proxy be used for HTTP requests?")
52     BotConfig.register BotConfigStringValue.new('http.proxy_uri', :default => false,
53       :desc => "Proxy server to use for HTTP requests (URI, e.g http://proxy.host:port)")
54     BotConfig.register BotConfigStringValue.new('http.proxy_user',
55       :default => nil,
56       :desc => "User for authenticating with the http proxy (if required)")
57     BotConfig.register BotConfigStringValue.new('http.proxy_pass',
58       :default => nil,
59       :desc => "Password for authenticating with the http proxy (if required)")
60     BotConfig.register BotConfigArrayValue.new('http.proxy_include',
61       :default => [],
62       :desc => "List of regexps to check against a URI's hostname/ip to see if we should use the proxy to access this URI. All URIs are proxied by default if the proxy is set, so this is only required to re-include URIs that might have been excluded by the exclude list. e.g. exclude /.*\.foo\.com/, include bar\.foo\.com")
63     BotConfig.register BotConfigArrayValue.new('http.proxy_exclude',
64       :default => [],
65       :desc => "List of regexps to check against a URI's hostname/ip to see if we should use avoid the proxy to access this URI and access it directly")
66     BotConfig.register BotConfigIntegerValue.new('http.max_redir',
67       :default => 5,
68       :desc => "Maximum number of redirections to be used when getting a document")
69     BotConfig.register BotConfigIntegerValue.new('http.expire_time',
70       :default => 60,
71       :desc => "After how many minutes since last use a cached document is considered to be expired")
72     BotConfig.register BotConfigIntegerValue.new('http.max_cache_time',
73       :default => 60*24,
74       :desc => "After how many minutes since first use a cached document is considered to be expired")
75     BotConfig.register BotConfigIntegerValue.new('http.no_expire_cache',
76       :default => false,
77       :desc => "Set this to true if you want the bot to never expire the cached pages")
78
79   def initialize(bot)
80     @bot = bot
81     @cache = Hash.new
82     @headers = {
83       'User-Agent' => "rbot http util #{$version} (http://linuxbrit.co.uk/rbot/)",
84     }
85     @last_response = nil
86   end
87   attr_reader :last_response
88   attr_reader :headers
89
90   # if http_proxy_include or http_proxy_exclude are set, then examine the
91   # uri to see if this is a proxied uri
92   # the in/excludes are a list of regexps, and each regexp is checked against
93   # the server name, and its IP addresses
94   def proxy_required(uri)
95     use_proxy = true
96     if @bot.config["http.proxy_exclude"].empty? && @bot.config["http.proxy_include"].empty?
97       return use_proxy
98     end
99
100     list = [uri.host]
101     begin
102       list.concat Resolv.getaddresses(uri.host)
103     rescue StandardError => err
104       warning "couldn't resolve host uri.host"
105     end
106
107     unless @bot.config["http.proxy_exclude"].empty?
108       re = @bot.config["http.proxy_exclude"].collect{|r| Regexp.new(r)}
109       re.each do |r|
110         list.each do |item|
111           if r.match(item)
112             use_proxy = false
113             break
114           end
115         end
116       end
117     end
118     unless @bot.config["http.proxy_include"].empty?
119       re = @bot.config["http.proxy_include"].collect{|r| Regexp.new(r)}
120       re.each do |r|
121         list.each do |item|
122           if r.match(item)
123             use_proxy = true
124             break
125           end
126         end
127       end
128     end
129     debug "using proxy for uri #{uri}?: #{use_proxy}"
130     return use_proxy
131   end
132
133   # uri:: Uri to create a proxy for
134   #
135   # return a net/http Proxy object, which is configured correctly for
136   # proxying based on the bot's proxy configuration.
137   # This will include per-url proxy configuration based on the bot config
138   # +http_proxy_include/exclude+ options.
139   def get_proxy(uri)
140     proxy = nil
141     proxy_host = nil
142     proxy_port = nil
143     proxy_user = nil
144     proxy_pass = nil
145
146     if @bot.config["http.use_proxy"]
147       if (ENV['http_proxy'])
148         proxy = URI.parse ENV['http_proxy'] rescue nil
149       end
150       if (@bot.config["http.proxy_uri"])
151         proxy = URI.parse @bot.config["http.proxy_uri"] rescue nil
152       end
153       if proxy
154         debug "proxy is set to #{proxy.host} port #{proxy.port}"
155         if proxy_required(uri)
156           proxy_host = proxy.host
157           proxy_port = proxy.port
158           proxy_user = @bot.config["http.proxy_user"]
159           proxy_pass = @bot.config["http.proxy_pass"]
160         end
161       end
162     end
163
164     h = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_port)
165     h.use_ssl = true if uri.scheme == "https"
166     return h
167   end
168
169   # uri::         uri to query (Uri object)
170   # readtimeout:: timeout for reading the response
171   # opentimeout:: timeout for opening the connection
172   #
173   # simple get request, returns (if possible) response body following redirs
174   # and caching if requested
175   # if a block is given, it yields the urls it gets redirected to
176   # TODO we really need something to implement proper caching
177   def get(uri_or_str, readtimeout=10, opentimeout=5, max_redir=@bot.config["http.max_redir"], cache=false)
178     if uri_or_str.kind_of?(URI)
179       uri = uri_or_str
180     else
181       uri = URI.parse(uri_or_str.to_s)
182     end
183     debug "Getting #{uri}"
184
185     proxy = get_proxy(uri)
186     proxy.open_timeout = opentimeout
187     proxy.read_timeout = readtimeout
188
189     begin
190       proxy.start() {|http|
191         yield uri.request_uri() if block_given?
192         req = Net::HTTP::Get.new(uri.request_uri(), @headers)
193         if uri.user and uri.password
194           req.basic_auth(uri.user, uri.password)
195         end
196         resp = http.request(req)
197         case resp
198         when Net::HTTPSuccess
199           if cache
200             debug "Caching #{uri.to_s}"
201             cache_response(uri.to_s, resp)
202           end
203           return resp.body
204         when Net::HTTPRedirection
205           if resp.key?('location')
206             new_loc = URI.join(uri, resp['location'])
207             debug "Redirecting #{uri} to #{new_loc}"
208             yield new_loc if block_given?
209             if max_redir > 0
210               # If cache is an Array, we assume get was called by get_cached
211               # because of a cache miss and that the first value of the Array
212               # was the noexpire value. Since the cache miss might have been
213               # caused by a redirection, we want to try get_cached again
214               # TODO FIXME look at Python's httplib2 for a most likely
215               # better way to handle all this mess
216               if cache.kind_of?(Array)
217                 return get_cached(new_loc, readtimeout, opentimeout, max_redir-1, cache[0])
218               else
219                 return get(new_loc, readtimeout, opentimeout, max_redir-1, cache)
220               end
221             else
222               warning "Max redirection reached, not going to #{new_loc}"
223             end
224           else
225             warning "Unknown HTTP redirection #{resp.inspect}"
226           end
227         else
228           debug "HttpUtil.get return code #{resp.code} #{resp.body}"
229         end
230         @last_response = resp
231         return nil
232       }
233     rescue StandardError, Timeout::Error => e
234       error "HttpUtil.get exception: #{e.inspect}, while trying to get #{uri}"
235       debug e.backtrace.join("\n")
236     end
237     @last_response = nil
238     return nil
239   end
240
241   # just like the above, but only gets the head
242   def head(uri_or_str, readtimeout=10, opentimeout=5, max_redir=@bot.config["http.max_redir"])
243     if uri_or_str.kind_of?(URI)
244       uri = uri_or_str
245     else
246       uri = URI.parse(uri_or_str.to_s)
247     end
248
249     proxy = get_proxy(uri)
250     proxy.open_timeout = opentimeout
251     proxy.read_timeout = readtimeout
252
253     begin
254       proxy.start() {|http|
255         yield uri.request_uri() if block_given?
256         req = Net::HTTP::Head.new(uri.request_uri(), @headers)
257         if uri.user and uri.password
258           req.basic_auth(uri.user, uri.password)
259         end
260         resp = http.request(req)
261         case resp
262         when Net::HTTPSuccess
263           return resp
264         when Net::HTTPRedirection
265           debug "Redirecting #{uri} to #{resp['location']}"
266           yield resp['location'] if block_given?
267           if max_redir > 0
268             return head( URI.parse(resp['location']), readtimeout, opentimeout, max_redir-1)
269           else
270             warning "Max redirection reached, not going to #{resp['location']}"
271           end
272         else
273           debug "HttpUtil.head return code #{resp.code}"
274         end
275         @last_response = resp
276         return nil
277       }
278     rescue StandardError, Timeout::Error => e
279       error "HttpUtil.head exception: #{e.inspect}, while trying to get #{uri}"
280       debug e.backtrace.join("\n")
281     end
282     @last_response = nil
283     return nil
284   end
285
286   # uri::         uri to query (Uri object or String)
287   # opts::        options. Currently used:
288   # :open_timeout::     open timeout for the proxy
289   # :read_timeout::     read timeout for the proxy
290   # :cache::            should we cache results?
291   #
292   # This method is used to get responses following redirections.
293   #
294   # It will return either a Net::HTTPResponse or an error.
295   #
296   # If a block is given, it will yield the response or error instead of
297   # returning it
298   #
299   def get_response(uri_or_str, opts={}, &block)
300     if uri_or_str.kind_of?(URI)
301       uri = uri_or_str
302     else
303       uri = URI.parse(uri_or_str.to_s)
304     end
305     debug "Getting #{uri}"
306
307     options = {
308       :read_timeout => 10,
309       :open_timeout => 5,
310       :max_redir => @bot.config["http.max_redir"],
311       :cache => false,
312       :yield => :none
313     }.merge(opts)
314
315     cache = options[:cache]
316
317     proxy = get_proxy(uri)
318     proxy.open_timeout = options[:open_timeout]
319     proxy.read_timeout = options[:read_timeout]
320
321     begin
322       proxy.start() {|http|
323         req = Net::HTTP::Get.new(uri.request_uri(), @headers)
324         if uri.user and uri.password
325           req.basic_auth(uri.user, uri.password)
326         end
327         http.request(req) { |resp|
328           case resp
329           when Net::HTTPSuccess
330             if cache
331               debug "Caching #{uri.to_s}"
332               cache_response(uri.to_s, resp)
333             end
334           when Net::HTTPRedirection
335             if resp.key?('location')
336               new_loc = URI.join(uri, resp['location']) rescue URI.parse(resp['location'])
337               debug "Redirecting #{uri} to #{new_loc}"
338               if options[:max_redir] > 0
339                 new_opts = options.dup
340                 new_opts[:max_redir] -= 1
341                 return get_response(new_loc, new_opts, &block)
342               else
343                 raise "Too many redirections"
344               end
345             end
346           end
347           if block_given?
348             yield resp
349           else
350             return resp
351           end
352         }
353       }
354     rescue StandardError, Timeout::Error => e
355       error "HttpUtil.get_response exception: #{e.inspect}, while trying to get #{uri}"
356       debug e.backtrace.join("\n")
357       def e.body
358         nil
359       end
360       if block_given?
361         yield e
362       else
363         return e
364       end
365     end
366
367     raise "This shouldn't happen"
368   end
369
370   def cache_response(k, resp)
371     begin
372       if resp.key?('pragma') and resp['pragma'] == 'no-cache'
373         debug "Not caching #{k}, it has Pragma: no-cache"
374         return
375       end
376       # TODO should we skip caching if neither last-modified nor etag are present?
377       now = Time.new
378       u = Hash.new
379       u = Hash.new
380       u[:body] = resp.body
381       u[:last_modified] = nil
382       u[:last_modified] = Time.httpdate(resp['date']) if resp.key?('date')
383       u[:last_modified] = Time.httpdate(resp['last-modified']) if resp.key?('last-modified')
384       u[:expires] = now
385       u[:expires] = Time.httpdate(resp['expires']) if resp.key?('expires')
386       u[:revalidate] = false
387       if resp.key?('cache-control')
388         # TODO max-age
389         case resp['cache-control']
390         when /no-cache|must-revalidate/
391           u[:revalidate] = true
392         end
393       end
394       u[:etag] = ""
395       u[:etag] = resp['etag'] if resp.key?('etag')
396       u[:count] = 1
397       u[:first_use] = now
398       u[:last_use] = now
399     rescue => e
400       error "Failed to cache #{k}/#{resp.to_hash.inspect}: #{e.inspect}"
401       return
402     end
403     @cache[k] = u
404     debug "Cached #{k}/#{resp.to_hash.inspect}: #{u.inspect_no_body}"
405     debug "#{@cache.size} pages (#{@cache.keys.join(', ')}) cached up to now"
406   end
407
408   # For debugging purposes
409   class ::Hash
410     def inspect_no_body
411       temp = self.dup
412       temp.delete(:body)
413       temp.inspect
414     end
415   end
416
417   def expired?(uri, readtimeout, opentimeout)
418     k = uri.to_s
419     debug "Checking cache validity for #{k}"
420     begin
421       return true unless @cache.key?(k)
422       u = @cache[k]
423
424       # TODO we always revalidate for the time being
425
426       if u[:etag].empty? and u[:last_modified].nil?
427         # TODO max-age
428         return true
429       end
430
431       proxy = get_proxy(uri)
432       proxy.open_timeout = opentimeout
433       proxy.read_timeout = readtimeout
434
435       proxy.start() {|http|
436         yield uri.request_uri() if block_given?
437         headers = @headers.dup
438         headers['If-None-Match'] = u[:etag] unless u[:etag].empty?
439         headers['If-Modified-Since'] = u[:last_modified].rfc2822 if u[:last_modified]
440         debug "Cache HEAD request headers: #{headers.inspect}"
441         # FIXME TODO We might want to use a Get here
442         # because if a 200 OK is returned we would get the new body
443         # with one connection less ...
444         req = Net::HTTP::Head.new(uri.request_uri(), headers)
445         if uri.user and uri.password
446           req.basic_auth(uri.user, uri.password)
447         end
448         resp = http.request(req)
449         debug "Checking cache validity of #{u.inspect_no_body} against #{resp.inspect}/#{resp.to_hash.inspect}"
450         case resp
451         when Net::HTTPNotModified
452           return false
453         else
454           return true
455         end
456       }
457     rescue => e
458       error "Failed to check cache validity for #{uri}: #{e.inspect}"
459       return true
460     end
461   end
462
463   # gets a page from the cache if it's still (assumed to be) valid
464   # TODO remove stale cached pages, except when called with noexpire=true
465   def get_cached(uri_or_str, readtimeout=10, opentimeout=5,
466                  max_redir=@bot.config['http.max_redir'],
467                  noexpire=@bot.config['http.no_expire_cache'])
468     if uri_or_str.kind_of?(URI)
469       uri = uri_or_str
470     else
471       uri = URI.parse(uri_or_str.to_s)
472     end
473     debug "Getting cached #{uri}"
474
475     if expired?(uri, readtimeout, opentimeout)
476       debug "Cache expired"
477       bod = get(uri, readtimeout, opentimeout, max_redir, [noexpire])
478       bod.instance_variable_set(:@cached,false)
479     else
480       k = uri.to_s
481       debug "Using cache"
482       @cache[k][:count] += 1
483       @cache[k][:last_use] = Time.now
484       bod = @cache[k][:body]
485       bod.instance_variable_set(:@cached,true)
486     end
487     unless noexpire
488       remove_stale_cache
489     end
490     unless bod.respond_to?(:cached?)
491       def bod.cached?
492         return @cached
493       end
494     end
495     return bod
496   end
497
498   # We consider a page to be manually expired if it has no
499   # etag and no last-modified and if any of the expiration
500   # conditions are met (expire_time, max_cache_time, Expires)
501   def manually_expired?(hash, time)
502     auto = hash[:etag].empty? and hash[:last_modified].nil?
503     # TODO max-age
504     manual = (time - hash[:last_use] > @bot.config['http.expire_time']*60) or
505              (time - hash[:first_use] > @bot.config['http.max_cache_time']*60) or
506              (hash[:expires] < time)
507     return (auto and manual)
508   end
509
510   def remove_stale_cache
511     debug "Removing stale cache"
512     debug "#{@cache.size} pages before"
513     begin
514     now = Time.new
515     @cache.reject! { |k, val|
516        manually_expired?(val, now)
517     }
518     rescue => e
519       error "Failed to remove stale cache: #{e.inspect}"
520     end
521     debug "#{@cache.size} pages after"
522   end
523 end
524 end
525 end