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