]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/httputil.rb
*** (httputil) major rework, new caching implementation, unified request
[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 # Author:: Dmitry "jsn" Kim <dmitry point kim at gmail point com>
9 #
10 # Copyright:: (C) 2002-2005 Tom Gilbert
11 # Copyright:: (C) 2006 Tom Gilbert, Giuseppe Bilotta
12 # Copyright:: (C) 2007 Giuseppe Bilotta, Dmitry Kim
13
14 require 'resolv'
15 require 'net/http'
16 begin
17   require 'net/https'
18 rescue LoadError => e
19   error "Couldn't load 'net/https':  #{e.inspect}"
20   error "Secured HTTP connections will fail"
21 end
22
23 Net::HTTP.version_1_2
24
25 module ::Irc
26 module Utils
27
28 # class for making http requests easier (mainly for plugins to use)
29 # this class can check the bot proxy configuration to determine if a proxy
30 # needs to be used, which includes support for per-url proxy configuration.
31 class HttpUtil
32     BotConfig.register BotConfigBooleanValue.new('http.use_proxy',
33       :default => false, :desc => "should a proxy be used for HTTP requests?")
34     BotConfig.register BotConfigStringValue.new('http.proxy_uri', :default => false,
35       :desc => "Proxy server to use for HTTP requests (URI, e.g http://proxy.host:port)")
36     BotConfig.register BotConfigStringValue.new('http.proxy_user',
37       :default => nil,
38       :desc => "User for authenticating with the http proxy (if required)")
39     BotConfig.register BotConfigStringValue.new('http.proxy_pass',
40       :default => nil,
41       :desc => "Password for authenticating with the http proxy (if required)")
42     BotConfig.register BotConfigArrayValue.new('http.proxy_include',
43       :default => [],
44       :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")
45     BotConfig.register BotConfigArrayValue.new('http.proxy_exclude',
46       :default => [],
47       :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")
48     BotConfig.register BotConfigIntegerValue.new('http.max_redir',
49       :default => 5,
50       :desc => "Maximum number of redirections to be used when getting a document")
51     BotConfig.register BotConfigIntegerValue.new('http.expire_time',
52       :default => 60,
53       :desc => "After how many minutes since last use a cached document is considered to be expired")
54     BotConfig.register BotConfigIntegerValue.new('http.max_cache_time',
55       :default => 60*24,
56       :desc => "After how many minutes since first use a cached document is considered to be expired")
57     BotConfig.register BotConfigIntegerValue.new('http.no_expire_cache',
58       :default => false,
59       :desc => "Set this to true if you want the bot to never expire the cached pages")
60     BotConfig.register BotConfigIntegerValue.new('http.info_bytes',
61       :default => 8192,
62       :desc => "How many bytes to download from a web page to find some information. Set to 0 to let the bot download the whole page.")
63
64   class CachedObject
65     attr_accessor :response, :last_used, :first_used, :count, :expires, :date
66
67     def self.maybe_new(resp)
68       debug "maybe new #{resp}"
69       return nil unless Net::HTTPOK === resp ||
70       Net::HTTPMovedPermanently === resp ||
71       Net::HTTPFound === resp ||
72       Net::HTTPPartialContent === resp
73
74       cc = resp['cache-control']
75       return nil if cc && (cc =~ /no-cache/i)
76
77       date = Time.now
78       if d = resp['date']
79         date = Time.httpdate(d)
80       end
81
82       return nil if resp['expires'] && (Time.httpdate(resp['expires']) < date)
83
84       debug "creating cache obj"
85
86       self.new(resp)
87     end
88
89     def use
90       now = Time.now
91       @first_used = now if @count == 0
92       @last_used = now
93       @count += 1
94     end
95
96     def expired?
97       debug "checking expired?"
98       if cc = self.response['cache-control'] && cc =~ /must-revalidate/
99         return true
100       end
101       return self.expires < Time.now
102     end
103
104     def setup_headers(hdr)
105       hdr['if-modified-since'] = self.date.rfc2822
106
107       debug "ims == #{hdr['if-modified-since']}"
108
109       if etag = self.response['etag']
110         hdr['if-none-match'] = etag
111         debug "etag: #{etag}"
112       end
113     end
114
115     def revalidate(resp = self.response)
116       @count = 0
117       self.use
118       self.date = resp.key?('date') ? Time.httpdate(resp['date']) : Time.now
119
120       cc = resp['cache-control']
121       if cc && (cc =~ /max-age=(\d+)/)
122         self.expires = self.date + $1.to_i
123       elsif resp.key?('expires')
124         self.expires = Time.httpdate(resp['expires'])
125       elsif lm = resp['last-modified']
126         delta = self.date - Time.httpdate(lm)
127         delta = 10 if delta <= 0
128         delta /= 5
129         self.expires = self.date + delta
130       else
131         self.expires = self.date + 300
132       end
133       # self.expires = Time.now + 10 # DEBUG
134       debug "expires on #{self.expires}"
135
136       return true
137     end
138
139     private
140     def initialize(resp)
141       @response = resp
142       begin
143         self.revalidate
144         self.response.body
145       rescue Exception => e
146         error e.message
147         error e.backtrace.join("\n")
148         raise e
149       end
150     end
151   end
152
153   def initialize(bot)
154     @bot = bot
155     @cache = Hash.new
156     @headers = {
157       'Accept-Charset' => 'utf-8;q=1.0, *;q=0.8',
158       'User-Agent' =>
159         "rbot http util #{$version} (http://linuxbrit.co.uk/rbot/)"
160     } 
161     debug "starting http cache cleanup timer"
162     @timer = @bot.timer.add(300) {
163       self.remove_stale_cache unless @bot.config['http.no_expire_cache']
164     }
165   end 
166
167   def cleanup
168     debug 'stopping http cache cleanup timer'
169     @bot.timer.remove(@timer)
170   end
171
172   # if http_proxy_include or http_proxy_exclude are set, then examine the
173   # uri to see if this is a proxied uri
174   # the in/excludes are a list of regexps, and each regexp is checked against
175   # the server name, and its IP addresses
176   def proxy_required(uri)
177     use_proxy = true
178     if @bot.config["http.proxy_exclude"].empty? && @bot.config["http.proxy_include"].empty?
179       return use_proxy
180     end
181
182     list = [uri.host]
183     begin
184       list.concat Resolv.getaddresses(uri.host)
185     rescue StandardError => err
186       warning "couldn't resolve host uri.host"
187     end
188
189     unless @bot.config["http.proxy_exclude"].empty?
190       re = @bot.config["http.proxy_exclude"].collect{|r| Regexp.new(r)}
191       re.each do |r|
192         list.each do |item|
193           if r.match(item)
194             use_proxy = false
195             break
196           end
197         end
198       end
199     end
200     unless @bot.config["http.proxy_include"].empty?
201       re = @bot.config["http.proxy_include"].collect{|r| Regexp.new(r)}
202       re.each do |r|
203         list.each do |item|
204           if r.match(item)
205             use_proxy = true
206             break
207           end
208         end
209       end
210     end
211     debug "using proxy for uri #{uri}?: #{use_proxy}"
212     return use_proxy
213   end
214
215   # uri:: Uri to create a proxy for
216   #
217   # return a net/http Proxy object, which is configured correctly for
218   # proxying based on the bot's proxy configuration.
219   # This will include per-url proxy configuration based on the bot config
220   # +http_proxy_include/exclude+ options.
221   
222   def get_proxy(uri, options = {})
223     opts = {
224       :read_timeout => 10,
225       :open_timeout => 5
226     }.merge(options)
227
228     proxy = nil
229     proxy_host = nil
230     proxy_port = nil
231     proxy_user = nil
232     proxy_pass = nil
233
234     if @bot.config["http.use_proxy"]
235       if (ENV['http_proxy'])
236         proxy = URI.parse ENV['http_proxy'] rescue nil
237       end
238       if (@bot.config["http.proxy_uri"])
239         proxy = URI.parse @bot.config["http.proxy_uri"] rescue nil
240       end
241       if proxy
242         debug "proxy is set to #{proxy.host} port #{proxy.port}"
243         if proxy_required(uri)
244           proxy_host = proxy.host
245           proxy_port = proxy.port
246           proxy_user = @bot.config["http.proxy_user"]
247           proxy_pass = @bot.config["http.proxy_pass"]
248         end
249       end
250     end
251
252     h = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_port)
253     h.use_ssl = true if uri.scheme == "https"
254
255     h.read_timeout = opts[:read_timeout]
256     h.open_timeout = opts[:open_timeout]
257     return h
258   end
259
260   def handle_response(uri, resp, opts, &block)
261     if Net::HTTPRedirection === resp && opts[:max_redir] >= 0
262       if resp.key?('location')
263         raise 'Too many redirections' if opts[:max_redir] <= 0
264         yield resp if opts[:yield] == :all && block_given?
265         loc = resp['location']
266         new_loc = URI.join(uri.to_s, loc) rescue URI.parse(loc)
267         new_opts = opts.dup
268         new_opts[:max_redir] -= 1
269         case opts[:method].to_s.downcase.intern
270         when :post, :"net::http::post"
271           new_opts[:method] = :get
272         end
273         debug "following the redirect to #{new_loc}"
274         return get_response(new_loc, new_opts, &block)
275       else
276         warning ":| redirect w/o location?"
277       end
278     end
279     if block_given?
280       yield(resp)
281     else
282       resp.body
283     end
284
285     return resp
286   end
287
288   # uri::         uri to query (Uri object or String)
289   # opts::        options. Currently used:
290   # :method::     request method [:get (default), :post or :head]
291   # :open_timeout::     open timeout for the proxy
292   # :read_timeout::     read timeout for the proxy
293   # :cache::            should we cache results?
294   # :yield::      if :final [default], call &block for the response object
295   #               if :all, call &block for all intermediate redirects, too
296   # :max_redir::  how many redirects to follow before raising the exception
297   #               if -1, don't follow redirects, just return them
298   # :range::      make a ranged request (usually GET). accepts a string
299   #               for HTTP/1.1 "Range:" header (i.e. "bytes=0-1000")
300   # :body::       request body (usually for POST requests)
301   #
302   # Generic http transaction method
303   #
304   # It will return a HTTP::Response object or raise an exception
305   #
306   # If a block is given, it will yield the response (see :yield option)
307
308   def get_response(uri_or_s, options = {}, &block)
309     uri = uri_or_s.kind_of?(URI) ? uri_or_s : URI.parse(uri_or_s.to_s)
310     opts = {
311       :max_redir => @bot.config['http.max_redir'],
312       :yield => :final,
313       :cache => true,
314       :method => :GET
315     }.merge(options)
316
317     resp = nil
318     cached = nil
319
320     req_class = case opts[:method].to_s.downcase.intern
321                 when :head, :"net::http::head"
322                   opts[:max_redir] = -1
323                   Net::HTTP::Head
324                 when :get, :"net::http::get"
325                   Net::HTTP::Get
326                 when :post, :"net::http::post"
327                   opts[:cache] = false
328                   opts[:body] or raise 'post request w/o a body?'
329                   warning "refusing to cache POST request" if options[:cache]
330                   Net::HTTP::Post
331                 else
332                   warning "unsupported method #{opts[:method]}, doing GET"
333                   Net::HTTP::Get
334                 end
335
336     if req_class != Net::HTTP::Get && opts[:range]
337       warning "can't request ranges for #{req_class}"
338       opts.delete(:range)
339     end
340
341     cache_key = "#{opts[:range]}|#{req_class}|#{uri.to_s}"
342
343     if req_class != Net::HTTP::Get && req_class != Net::HTTP::Head
344       if opts[:cache]
345         warning "can't cache #{req_class.inspect} requests, working w/o cache"
346         opts[:cache] = false
347       end
348     end
349
350     debug "get_response(#{uri}, #{opts.inspect})"
351
352     if opts[:cache] && cached = @cache[cache_key]
353       debug "got cached"
354       if !cached.expired?
355         debug "using cached"
356         cached.use
357         return handle_response(uri, cached.response, opts, &block)
358       end
359     end
360     
361     headers = @headers.dup.merge(opts[:headers] || {})
362     headers['Range'] = opts[:range] if opts[:range]
363
364     cached.setup_headers(headers) if cached && (req_class == Net::HTTP::Get)
365     req = req_class.new(uri.request_uri, headers)
366     req.basic_auth(uri.user, uri.password) if uri.user && uri.password
367     req.body = opts[:body] if req_class == Net::HTTP::Post
368     debug "prepared request: #{req.to_hash.inspect}"
369
370     get_proxy(uri, opts).start do |http|
371       http.request(req) do |resp|
372         if Net::HTTPNotModified === resp
373           debug "not modified"
374           begin
375             cached.revalidate(resp)
376           rescue Exception => e
377             error e.message
378             error e.backtrace.join("\n")
379           end
380           debug "reusing cached"
381           resp = cached.response
382         elsif Net::HTTPServerError === resp || Net::HTTPClientError === resp
383           debug "http error, deleting cached obj" if cached
384           @cache.delete(cache_key)
385         elsif opts[:cache] && cached = CachedObject.maybe_new(resp) rescue nil
386           debug "storing to cache"
387           @cache[cache_key] = cached
388         end
389         return handle_response(uri, resp, opts, &block)
390       end
391     end
392   end
393
394   # uri::         uri to query (Uri object)
395   #
396   # simple get request, returns (if possible) response body following redirs
397   # and caching if requested
398   def get(uri, opts = {}, &block)
399     begin
400       resp = get_response(uri, opts, &block)
401       raise "http error: #{resp}" unless Net::HTTPOK === resp ||
402         Net::HTTPPartialContent === resp
403       return resp.body
404     rescue Exception => e
405       error e.message
406       error e.backtrace.join("\n")
407     end
408     return nil
409   end
410
411   def head(uri, options = {}, &block)
412     opts = {:method => :head}.merge(options)
413     begin
414       resp = get_response(uri, opts, &block)
415       raise "http error #{resp}" if Net::HTTPClientError === resp ||
416         Net::HTTPServerError == resp
417       return resp
418     rescue Exception => e
419       error e.message
420       error e.backtrace.join("\n")
421     end
422     return nil
423   end
424
425   def post(uri, data, options = {}, &block)
426     opts = {:method => :post, :body => data, :cache => false}.merge(options)
427     begin
428       resp = get_response(uri, opts, &block)
429       raise 'http error' unless Net::HTTPOK === resp
430       return resp
431     rescue Exception => e
432       error e.message
433       error e.backtrace.join("\n")
434     end
435     return nil
436   end
437
438   def get_partial(uri, nbytes = @bot.config['http.info_bytes'], options = {}, &block)
439     opts = {:range => "bytes=0-#{nbytes}"}.merge(options)
440     return get(uri, opts, &block)
441   end
442
443   def remove_stale_cache
444     debug "Removing stale cache"
445     now = Time.new
446     max_last = @bot.config['http.expire_time'] * 60
447     max_first = @bot.config['http.max_cache_time'] * 60
448     debug "#{@cache.size} pages before"
449     begin
450       @cache.reject! { |k, val|
451         (now - val.last_used > max_last) || (now - val.first_used > max_first)
452       }
453     rescue => e
454       error "Failed to remove stale cache: #{e.inspect}"
455     end
456     debug "#{@cache.size} pages after"
457   end
458
459 end
460 end
461 end
462
463 class HttpUtilPlugin < CoreBotModule
464   def initialize(*a)
465     super(*a)
466     debug 'initializing httputil'
467     @bot.httputil = Irc::Utils::HttpUtil.new(@bot)
468   end
469
470   def cleanup
471     debug 'shutting down httputil'
472     @bot.httputil.cleanup
473     @bot.httputil = nil
474   end
475 end
476
477 HttpUtilPlugin.new