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