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