]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/httputil.rb
httputil: work (sub-par) without iconv
[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 require 'resolv'
11 require 'net/http'
12 require 'cgi'
13 begin
14   require 'iconv'
15 rescue LoadError => e
16   error "Couldn't load 'iconv':  #{e}"
17   error "Non-UTF-8 webpages will not be properly supported"
18 end
19
20 begin
21   require 'net/https'
22 rescue LoadError => e
23   error "Couldn't load 'net/https':  #{e}"
24   error "Secured HTTP connections will fail"
25   # give a nicer error than "undefined method `use_ssl='"
26   ::Net::HTTP.class_eval <<-EOC
27     define_method :use_ssl= do |val|
28       # does anybody really set it to false?
29       break if !val
30       raise _("I can't do secure HTTP, sorry (%{msg})") % {
31         :msg => e.message
32       }
33     end
34   EOC
35 end
36
37 # To handle Gzipped pages
38 require 'stringio'
39 require 'zlib'
40
41 module ::Net
42   class HTTPResponse
43     attr_accessor :no_cache
44     unless method_defined? :raw_body
45       alias :raw_body :body
46     end
47
48     def body_charset(str=self.raw_body)
49       ctype = self['content-type'] || 'text/html'
50       return nil unless ctype =~ /^text/i || ctype =~ /x(ht)?ml/i
51
52       charsets = ['latin1'] # should be in config
53
54       if ctype.match(/charset=["']?([^\s"']+)["']?/i)
55         charsets << $1
56         debug "charset #{charsets.last} added from header"
57       end
58
59       case str
60       when /<\?xml\s[^>]*encoding=['"]([^\s"'>]+)["'][^>]*\?>/i
61         charsets << $1
62         debug "xml charset #{charsets.last} added from xml pi"
63       when /<(meta\s[^>]*http-equiv=["']?Content-Type["']?[^>]*)>/i
64         meta = $1
65         if meta =~ /charset=['"]?([^\s'";]+)['"]?/
66           charsets << $1
67           debug "html charset #{charsets.last} added from meta"
68         end
69       end
70       return charsets.uniq
71     end
72
73     def body_to_utf(str)
74       charsets = self.body_charset(str) or return str
75       return str unless defined? Iconv
76
77       charsets.reverse_each do |charset|
78         # XXX: this one is really ugly, but i don't know how to make it better
79         #  -jsn
80
81         0.upto(5) do |off|
82           begin
83             debug "trying #{charset} / offset #{off}"
84             return Iconv.iconv('utf-8//ignore',
85                                charset,
86                                str.slice(0 .. (-1 - off))).first
87           rescue
88             debug "conversion failed for #{charset} / offset #{off}"
89           end
90         end
91       end
92       return str
93     end
94
95     def decompress_body(str)
96       method = self['content-encoding']
97       case method
98       when nil
99         return str
100       when /gzip/ # Matches gzip, x-gzip, and the non-rfc-compliant gzip;q=\d sent by some servers
101         debug "gunzipping body"
102         begin
103           return Zlib::GzipReader.new(StringIO.new(str)).read
104         rescue Zlib::Error => e
105           # If we can't unpack the whole stream (e.g. because we're doing a
106           # partial read
107           debug "full gunzipping failed (#{e}), trying to recover as much as possible"
108           ret = ""
109           begin
110             Zlib::GzipReader.new(StringIO.new(str)).each_byte { |byte|
111               ret << byte
112             }
113           rescue
114           end
115           return ret
116         end
117       when 'deflate'
118         debug "inflating body"
119         # From http://www.koders.com/ruby/fid927B4382397E5115AC0ABE21181AB5C1CBDD5C17.aspx?s=thread:
120         # -MAX_WBITS stops zlib from looking for a zlib header
121         inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
122         begin
123           return inflater.inflate(str)
124         rescue Zlib::Error => e
125           raise e
126           # TODO
127           # debug "full inflation failed (#{e}), trying to recover as much as possible"
128         end
129       when /^(?:iso-8859-\d+|windows-\d+|utf-8|utf8)$/i
130         # B0rked servers (Freshmeat being one of them) sometimes return the charset
131         # in the content-encoding; in this case we assume that the document has
132         # a standard content-encoding
133         old_hsh = self.to_hash
134         self['content-type']= self['content-type']+"; charset="+method.downcase
135         warning "Charset vs content-encoding confusion, trying to recover: from\n#{old_hsh.pretty_inspect}to\n#{self.to_hash.pretty_inspect}"
136         return str
137       else
138         debug self.to_hash
139         raise "Unhandled content encoding #{method}"
140       end
141     end
142
143     def cooked_body
144       return self.body_to_utf(self.decompress_body(self.raw_body))
145     end
146
147     # Read chunks from the body until we have at least _size_ bytes, yielding
148     # the partial text at each chunk. Return the partial body.
149     def partial_body(size=0, &block)
150
151       partial = String.new
152
153       if @read
154         debug "using body() as partial"
155         partial = self.body
156         yield self.body_to_utf(self.decompress_body(partial)) if block_given?
157       else
158         debug "disabling cache"
159         self.no_cache = true
160         self.read_body { |chunk|
161           partial << chunk
162           yield self.body_to_utf(self.decompress_body(partial)) if block_given?
163           break if size and size > 0 and partial.length >= size
164         }
165       end
166
167       return self.body_to_utf(self.decompress_body(partial))
168     end
169   end
170 end
171
172 Net::HTTP.version_1_2
173
174 module ::Irc
175 module Utils
176
177 # class for making http requests easier (mainly for plugins to use)
178 # this class can check the bot proxy configuration to determine if a proxy
179 # needs to be used, which includes support for per-url proxy configuration.
180 class HttpUtil
181     Bot::Config.register Bot::Config::IntegerValue.new('http.read_timeout',
182       :default => 10, :desc => "Default read timeout for HTTP connections")
183     Bot::Config.register Bot::Config::IntegerValue.new('http.open_timeout',
184       :default => 20, :desc => "Default open timeout for HTTP connections")
185     Bot::Config.register Bot::Config::BooleanValue.new('http.use_proxy',
186       :default => false, :desc => "should a proxy be used for HTTP requests?")
187     Bot::Config.register Bot::Config::StringValue.new('http.proxy_uri', :default => false,
188       :desc => "Proxy server to use for HTTP requests (URI, e.g http://proxy.host:port)")
189     Bot::Config.register Bot::Config::StringValue.new('http.proxy_user',
190       :default => nil,
191       :desc => "User for authenticating with the http proxy (if required)")
192     Bot::Config.register Bot::Config::StringValue.new('http.proxy_pass',
193       :default => nil,
194       :desc => "Password for authenticating with the http proxy (if required)")
195     Bot::Config.register Bot::Config::ArrayValue.new('http.proxy_include',
196       :default => [],
197       :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")
198     Bot::Config.register Bot::Config::ArrayValue.new('http.proxy_exclude',
199       :default => [],
200       :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")
201     Bot::Config.register Bot::Config::IntegerValue.new('http.max_redir',
202       :default => 5,
203       :desc => "Maximum number of redirections to be used when getting a document")
204     Bot::Config.register Bot::Config::IntegerValue.new('http.expire_time',
205       :default => 60,
206       :desc => "After how many minutes since last use a cached document is considered to be expired")
207     Bot::Config.register Bot::Config::IntegerValue.new('http.max_cache_time',
208       :default => 60*24,
209       :desc => "After how many minutes since first use a cached document is considered to be expired")
210     Bot::Config.register Bot::Config::BooleanValue.new('http.no_expire_cache',
211       :default => false,
212       :desc => "Set this to true if you want the bot to never expire the cached pages")
213     Bot::Config.register Bot::Config::IntegerValue.new('http.info_bytes',
214       :default => 8192,
215       :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.")
216
217   class CachedObject
218     attr_accessor :response, :last_used, :first_used, :count, :expires, :date
219
220     def self.maybe_new(resp)
221       debug "maybe new #{resp}"
222       return nil if resp.no_cache
223       return nil unless Net::HTTPOK === resp ||
224       Net::HTTPMovedPermanently === resp ||
225       Net::HTTPFound === resp ||
226       Net::HTTPPartialContent === resp
227
228       cc = resp['cache-control']
229       return nil if cc && (cc =~ /no-cache/i)
230
231       date = Time.now
232       if d = resp['date']
233         date = Time.httpdate(d)
234       end
235
236       return nil if resp['expires'] && (Time.httpdate(resp['expires']) < date)
237
238       debug "creating cache obj"
239
240       self.new(resp)
241     end
242
243     def use
244       now = Time.now
245       @first_used = now if @count == 0
246       @last_used = now
247       @count += 1
248     end
249
250     def expired?
251       debug "checking expired?"
252       if cc = self.response['cache-control'] && cc =~ /must-revalidate/
253         return true
254       end
255       return self.expires < Time.now
256     end
257
258     def setup_headers(hdr)
259       hdr['if-modified-since'] = self.date.rfc2822
260
261       debug "ims == #{hdr['if-modified-since']}"
262
263       if etag = self.response['etag']
264         hdr['if-none-match'] = etag
265         debug "etag: #{etag}"
266       end
267     end
268
269     def revalidate(resp = self.response)
270       @count = 0
271       self.use
272       self.date = resp.key?('date') ? Time.httpdate(resp['date']) : Time.now
273
274       cc = resp['cache-control']
275       if cc && (cc =~ /max-age=(\d+)/)
276         self.expires = self.date + $1.to_i
277       elsif resp.key?('expires')
278         self.expires = Time.httpdate(resp['expires'])
279       elsif lm = resp['last-modified']
280         delta = self.date - Time.httpdate(lm)
281         delta = 10 if delta <= 0
282         delta /= 5
283         self.expires = self.date + delta
284       else
285         self.expires = self.date + 300
286       end
287       # self.expires = Time.now + 10 # DEBUG
288       debug "expires on #{self.expires}"
289
290       return true
291     end
292
293     private
294     def initialize(resp)
295       @response = resp
296       begin
297         self.revalidate
298         self.response.raw_body
299       rescue Exception => e
300         error e
301         raise e
302       end
303     end
304   end
305
306   # Create the HttpUtil instance, associating it with Bot _bot_
307   #
308   def initialize(bot)
309     @bot = bot
310     @cache = Hash.new
311     @headers = {
312       'Accept-Charset' => 'utf-8;q=1.0, *;q=0.8',
313       'Accept-Encoding' => 'gzip;q=1, deflate;q=1, identity;q=0.8, *;q=0.2',
314       'User-Agent' =>
315         "rbot http util #{$version} (#{Irc::Bot::SOURCE_URL})"
316     }
317     debug "starting http cache cleanup timer"
318     @timer = @bot.timer.add(300) {
319       self.remove_stale_cache unless @bot.config['http.no_expire_cache']
320     }
321   end
322
323   # Clean up on HttpUtil unloading, by stopping the cache cleanup timer.
324   def cleanup
325     debug 'stopping http cache cleanup timer'
326     @bot.timer.remove(@timer)
327   end
328
329   # This method checks if a proxy is required to access _uri_, by looking at
330   # the values of config values +http.proxy_include+ and +http.proxy_exclude+.
331   #
332   # Each of these config values, if set, should be a Regexp the server name and
333   # IP address should be checked against.
334   #
335   def proxy_required(uri)
336     use_proxy = true
337     if @bot.config["http.proxy_exclude"].empty? && @bot.config["http.proxy_include"].empty?
338       return use_proxy
339     end
340
341     list = [uri.host]
342     begin
343       list.concat Resolv.getaddresses(uri.host)
344     rescue StandardError => err
345       warning "couldn't resolve host uri.host"
346     end
347
348     unless @bot.config["http.proxy_exclude"].empty?
349       re = @bot.config["http.proxy_exclude"].collect{|r| Regexp.new(r)}
350       re.each do |r|
351         list.each do |item|
352           if r.match(item)
353             use_proxy = false
354             break
355           end
356         end
357       end
358     end
359     unless @bot.config["http.proxy_include"].empty?
360       re = @bot.config["http.proxy_include"].collect{|r| Regexp.new(r)}
361       re.each do |r|
362         list.each do |item|
363           if r.match(item)
364             use_proxy = true
365             break
366           end
367         end
368       end
369     end
370     debug "using proxy for uri #{uri}?: #{use_proxy}"
371     return use_proxy
372   end
373
374   # _uri_:: URI to create a proxy for
375   #
376   # Return a net/http Proxy object, configured for proxying based on the
377   # bot's proxy configuration. See proxy_required for more details on this.
378   #
379   def get_proxy(uri, options = {})
380     opts = {
381       :read_timeout => @bot.config["http.read_timeout"],
382       :open_timeout => @bot.config["http.open_timeout"]
383     }.merge(options)
384
385     proxy = nil
386     proxy_host = nil
387     proxy_port = nil
388     proxy_user = nil
389     proxy_pass = nil
390
391     if @bot.config["http.use_proxy"]
392       if (ENV['http_proxy'])
393         proxy = URI.parse ENV['http_proxy'] rescue nil
394       end
395       if (@bot.config["http.proxy_uri"])
396         proxy = URI.parse @bot.config["http.proxy_uri"] rescue nil
397       end
398       if proxy
399         debug "proxy is set to #{proxy.host} port #{proxy.port}"
400         if proxy_required(uri)
401           proxy_host = proxy.host
402           proxy_port = proxy.port
403           proxy_user = @bot.config["http.proxy_user"]
404           proxy_pass = @bot.config["http.proxy_pass"]
405         end
406       end
407     end
408
409     h = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_pass)
410     h.use_ssl = true if uri.scheme == "https"
411
412     h.read_timeout = opts[:read_timeout]
413     h.open_timeout = opts[:open_timeout]
414     return h
415   end
416
417   # Internal method used to hanlde response _resp_ received when making a
418   # request for URI _uri_.
419   #
420   # It follows redirects, optionally yielding them if option :yield is :all.
421   #
422   # Also yields and returns the final _resp_.
423   #
424   def handle_response(uri, resp, opts, &block) # :yields: resp
425     if Net::HTTPRedirection === resp && opts[:max_redir] >= 0
426       if resp.key?('location')
427         raise 'Too many redirections' if opts[:max_redir] <= 0
428         yield resp if opts[:yield] == :all && block_given?
429         # some servers actually provide unescaped location, e.g.
430         # http://ulysses.soup.io/post/60734021/Image%20curve%20ball
431         # rediects to something like
432         # http://ulysses.soup.io/post/60734021/Image curve ball?sessid=8457b2a3752085cca3fb1d79b9965446
433         # causing the URI parser to (obviously) complain. We cannot just
434         # escape blindly, as this would make a mess of already-escaped
435         # locations, so we only do it if the URI.parse fails
436         loc = resp['location']
437         escaped = false
438         debug "redirect location: #{loc.inspect}"
439         begin
440           new_loc = URI.join(uri.to_s, loc) rescue URI.parse(loc)
441         rescue
442           if escaped
443             raise $!
444           else
445             loc = URI.escape(loc)
446             escaped = true
447             debug "escaped redirect location: #{loc.inspect}"
448             retry
449           end
450         end
451         new_opts = opts.dup
452         new_opts[:max_redir] -= 1
453         case opts[:method].to_s.downcase.intern
454         when :post, :"net::http::post"
455           new_opts[:method] = :get
456         end
457         if resp['set-cookie']
458           debug "set cookie request for #{resp['set-cookie']}"
459           cookie, cookie_flags = (resp['set-cookie']+'; ').split('; ', 2)
460           domain = uri.host
461           cookie_flags.scan(/(\S+)=(\S+);/) { |key, val|
462             if key.intern == :domain
463               domain = val
464               break
465             end
466           }
467           debug "cookie domain #{domain} / #{new_loc.host}"
468           if new_loc.host.rindex(domain) == new_loc.host.length - domain.length
469             debug "setting cookie"
470             new_opts[:headers] ||= Hash.new
471             new_opts[:headers]['Cookie'] = cookie
472           else
473             debug "cookie is for another domain, ignoring"
474           end
475         end
476         debug "following the redirect to #{new_loc}"
477         return get_response(new_loc, new_opts, &block)
478       else
479         warning ":| redirect w/o location?"
480       end
481     end
482     class << resp
483       undef_method :body
484       alias :body :cooked_body
485     end
486     unless resp['content-type']
487       debug "No content type, guessing"
488       resp['content-type'] =
489         case resp['x-rbot-location']
490         when /.html?$/i
491           'text/html'
492         when /.xml$/i
493           'application/xml'
494         when /.xhtml$/i
495           'application/xml+xhtml'
496         when /.(gif|png|jpe?g|jp2|tiff?)$/i
497           "image/#{$1.sub(/^jpg$/,'jpeg').sub(/^tif$/,'tiff')}"
498         else
499           'application/octetstream'
500         end
501     end
502     if block_given?
503       yield(resp)
504     else
505       # Net::HTTP wants us to read the whole body here
506       resp.raw_body
507     end
508     return resp
509   end
510
511   # _uri_::     uri to query (URI object or String)
512   #
513   # Generic http transaction method. It will return a Net::HTTPResponse
514   # object or raise an exception
515   #
516   # If a block is given, it will yield the response (see :yield option)
517   #
518   # Currently supported _options_:
519   #
520   # method::     request method [:get (default), :post or :head]
521   # open_timeout::     open timeout for the proxy
522   # read_timeout::     read timeout for the proxy
523   # cache::            should we cache results?
524   # yield::      if :final [default], calls the block for the response object;
525   #              if :all, call the block for all intermediate redirects, too
526   # max_redir::  how many redirects to follow before raising the exception
527   #              if -1, don't follow redirects, just return them
528   # range::      make a ranged request (usually GET). accepts a string
529   #              for HTTP/1.1 "Range:" header (i.e. "bytes=0-1000")
530   # body::       request body (usually for POST requests)
531   # headers::    additional headers to be set for the request. Its value must
532   #              be a Hash in the form { 'Header' => 'value' }
533   #
534   def get_response(uri_or_s, options = {}, &block) # :yields: resp
535     uri = uri_or_s.kind_of?(URI) ? uri_or_s : URI.parse(uri_or_s.to_s)
536     unless URI::HTTP === uri
537       if uri.scheme
538         raise "#{uri.scheme.inspect} URI scheme is not supported"
539       else
540         raise "don't know what to do with #{uri.to_s.inspect}"
541       end
542     end
543
544     opts = {
545       :max_redir => @bot.config['http.max_redir'],
546       :yield => :final,
547       :cache => true,
548       :method => :GET
549     }.merge(options)
550
551     req_class = case opts[:method].to_s.downcase.intern
552                 when :head, :"net::http::head"
553                   opts[:max_redir] = -1
554                   Net::HTTP::Head
555                 when :get, :"net::http::get"
556                   Net::HTTP::Get
557                 when :post, :"net::http::post"
558                   opts[:cache] = false
559                   opts[:body] or raise 'post request w/o a body?'
560                   warning "refusing to cache POST request" if options[:cache]
561                   Net::HTTP::Post
562                 else
563                   warning "unsupported method #{opts[:method]}, doing GET"
564                   Net::HTTP::Get
565                 end
566
567     if req_class != Net::HTTP::Get && opts[:range]
568       warning "can't request ranges for #{req_class}"
569       opts.delete(:range)
570     end
571
572     cache_key = "#{opts[:range]}|#{req_class}|#{uri.to_s}"
573
574     if req_class != Net::HTTP::Get && req_class != Net::HTTP::Head
575       if opts[:cache]
576         warning "can't cache #{req_class.inspect} requests, working w/o cache"
577         opts[:cache] = false
578       end
579     end
580
581     debug "get_response(#{uri}, #{opts.inspect})"
582
583     cached = @cache[cache_key]
584
585     if opts[:cache] && cached
586       debug "got cached"
587       if !cached.expired?
588         debug "using cached"
589         cached.use
590         return handle_response(uri, cached.response, opts, &block)
591       end
592     end
593
594     headers = @headers.dup.merge(opts[:headers] || {})
595     headers['Range'] = opts[:range] if opts[:range]
596     headers['Authorization'] = opts[:auth_head] if opts[:auth_head]
597
598     if opts[:cache] && cached && (req_class == Net::HTTP::Get)
599       cached.setup_headers headers
600     end
601
602     req = req_class.new(uri.request_uri, headers)
603     if uri.user && uri.password
604       req.basic_auth(uri.user, uri.password)
605       opts[:auth_head] = req['Authorization']
606     end
607     req.body = opts[:body] if req_class == Net::HTTP::Post
608     debug "prepared request: #{req.to_hash.inspect}"
609
610     begin
611       get_proxy(uri, opts).start do |http|
612         http.request(req) do |resp|
613           resp['x-rbot-location'] = uri.to_s
614           if Net::HTTPNotModified === resp
615             debug "not modified"
616             begin
617               cached.revalidate(resp)
618             rescue Exception => e
619               error e
620             end
621             debug "reusing cached"
622             resp = cached.response
623           elsif Net::HTTPServerError === resp || Net::HTTPClientError === resp
624             debug "http error, deleting cached obj" if cached
625             @cache.delete(cache_key)
626           end
627
628           begin
629             return handle_response(uri, resp, opts, &block)
630           ensure
631             if cached = CachedObject.maybe_new(resp) rescue nil
632               debug "storing to cache"
633               @cache[cache_key] = cached
634             end
635           end
636         end
637       end
638     rescue Exception => e
639       error e
640       raise e.message
641     end
642   end
643
644   # _uri_::     uri to query (URI object or String)
645   #
646   # Simple GET request, returns (if possible) response body following redirs
647   # and caching if requested, yielding the actual response(s) to the optional
648   # block. See get_response for details on the supported _options_
649   #
650   def get(uri, options = {}, &block) # :yields: resp
651     begin
652       resp = get_response(uri, options, &block)
653       raise "http error: #{resp}" unless Net::HTTPOK === resp ||
654         Net::HTTPPartialContent === resp
655       return resp.body
656     rescue Exception => e
657       error e
658     end
659     return nil
660   end
661
662   # _uri_::     uri to query (URI object or String)
663   #
664   # Simple HEAD request, returns (if possible) response head following redirs
665   # and caching if requested, yielding the actual response(s) to the optional
666   # block. See get_response for details on the supported _options_
667   #
668   def head(uri, options = {}, &block) # :yields: resp
669     opts = {:method => :head}.merge(options)
670     begin
671       resp = get_response(uri, opts, &block)
672       # raise "http error #{resp}" if Net::HTTPClientError === resp ||
673       #   Net::HTTPServerError == resp
674       return resp
675     rescue Exception => e
676       error e
677     end
678     return nil
679   end
680
681   # _uri_::     uri to query (URI object or String)
682   # _data_::    body of the POST
683   #
684   # Simple POST request, returns (if possible) response following redirs and
685   # caching if requested, yielding the response(s) to the optional block. See
686   # get_response for details on the supported _options_
687   #
688   def post(uri, data, options = {}, &block) # :yields: resp
689     opts = {:method => :post, :body => data, :cache => false}.merge(options)
690     begin
691       resp = get_response(uri, opts, &block)
692       raise 'http error' unless Net::HTTPOK === resp or Net::HTTPCreated === resp
693       return resp
694     rescue Exception => e
695       error e
696     end
697     return nil
698   end
699
700   # _uri_::     uri to query (URI object or String)
701   # _nbytes_::  number of bytes to get
702   #
703   # Partial GET request, returns (if possible) the first _nbytes_ bytes of the
704   # response body, following redirs and caching if requested, yielding the
705   # actual response(s) to the optional block. See get_response for details on
706   # the supported _options_
707   #
708   def get_partial(uri, nbytes = @bot.config['http.info_bytes'], options = {}, &block) # :yields: resp
709     opts = {:range => "bytes=0-#{nbytes}"}.merge(options)
710     return get(uri, opts, &block)
711   end
712
713   def remove_stale_cache
714     debug "Removing stale cache"
715     now = Time.new
716     max_last = @bot.config['http.expire_time'] * 60
717     max_first = @bot.config['http.max_cache_time'] * 60
718     debug "#{@cache.size} pages before"
719     begin
720       @cache.reject! { |k, val|
721         (now - val.last_used > max_last) || (now - val.first_used > max_first)
722       }
723     rescue => e
724       error "Failed to remove stale cache: #{e.pretty_inspect}"
725     end
726     debug "#{@cache.size} pages after"
727   end
728
729 end
730 end
731 end
732
733 class HttpUtilPlugin < CoreBotModule
734   def initialize(*a)
735     super(*a)
736     debug 'initializing httputil'
737     @bot.httputil = Irc::Utils::HttpUtil.new(@bot)
738   end
739
740   def cleanup
741     debug 'shutting down httputil'
742     @bot.httputil.cleanup
743     @bot.httputil = nil
744     super
745   end
746 end
747
748 HttpUtilPlugin.new