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