]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/httputil.rb
HttpUtil: require 'cgi' as it is now used in most querying plugins
[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.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 self['content-type'].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 { |charset|
65         begin
66           return Iconv.iconv('utf-8//ignore', charset, str).first
67         rescue
68           debug "conversion failed for #{charset}"
69         end
70       }
71       return str
72     end
73
74     def decompress_body(str)
75       method = self['content-encoding']
76       case method
77       when nil
78         return str
79       when 'gzip', 'x-gzip'
80         debug "gunzipping body"
81         return Zlib::GzipReader.new(StringIO.new(str)).read
82       else
83         raise "Unhandled content encoding #{method}"
84       end
85     end
86
87     def body
88       return self.body_to_utf(self.decompress_body(self.raw_body))
89     end
90
91     # Read chunks from the body until we have at least _size_ bytes, yielding 
92     # the partial text at each chunk. Return the partial body. 
93     def partial_body(size=0, &block) 
94
95       self.no_cache = true
96       partial = String.new 
97
98       self.read_body { |chunk| 
99         partial << chunk 
100         yield self.body_to_utf(partial) if block_given? 
101         break if size and size > 0 and partial.length >= size 
102       } 
103
104       return self.body_to_utf(partial)
105     end 
106   end 
107 end
108
109 Net::HTTP.version_1_2
110
111 module ::Irc
112 module Utils
113
114 # class for making http requests easier (mainly for plugins to use)
115 # this class can check the bot proxy configuration to determine if a proxy
116 # needs to be used, which includes support for per-url proxy configuration.
117 class HttpUtil
118     BotConfig.register BotConfigBooleanValue.new('http.use_proxy',
119       :default => false, :desc => "should a proxy be used for HTTP requests?")
120     BotConfig.register BotConfigStringValue.new('http.proxy_uri', :default => false,
121       :desc => "Proxy server to use for HTTP requests (URI, e.g http://proxy.host:port)")
122     BotConfig.register BotConfigStringValue.new('http.proxy_user',
123       :default => nil,
124       :desc => "User for authenticating with the http proxy (if required)")
125     BotConfig.register BotConfigStringValue.new('http.proxy_pass',
126       :default => nil,
127       :desc => "Password for authenticating with the http proxy (if required)")
128     BotConfig.register BotConfigArrayValue.new('http.proxy_include',
129       :default => [],
130       :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")
131     BotConfig.register BotConfigArrayValue.new('http.proxy_exclude',
132       :default => [],
133       :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")
134     BotConfig.register BotConfigIntegerValue.new('http.max_redir',
135       :default => 5,
136       :desc => "Maximum number of redirections to be used when getting a document")
137     BotConfig.register BotConfigIntegerValue.new('http.expire_time',
138       :default => 60,
139       :desc => "After how many minutes since last use a cached document is considered to be expired")
140     BotConfig.register BotConfigIntegerValue.new('http.max_cache_time',
141       :default => 60*24,
142       :desc => "After how many minutes since first use a cached document is considered to be expired")
143     BotConfig.register BotConfigIntegerValue.new('http.no_expire_cache',
144       :default => false,
145       :desc => "Set this to true if you want the bot to never expire the cached pages")
146     BotConfig.register BotConfigIntegerValue.new('http.info_bytes',
147       :default => 8192,
148       :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.")
149
150   class CachedObject
151     attr_accessor :response, :last_used, :first_used, :count, :expires, :date
152
153     def self.maybe_new(resp)
154       debug "maybe new #{resp}"
155       return nil if resp.no_cache
156       return nil unless Net::HTTPOK === resp ||
157       Net::HTTPMovedPermanently === resp ||
158       Net::HTTPFound === resp ||
159       Net::HTTPPartialContent === resp
160
161       cc = resp['cache-control']
162       return nil if cc && (cc =~ /no-cache/i)
163
164       date = Time.now
165       if d = resp['date']
166         date = Time.httpdate(d)
167       end
168
169       return nil if resp['expires'] && (Time.httpdate(resp['expires']) < date)
170
171       debug "creating cache obj"
172
173       self.new(resp)
174     end
175
176     def use
177       now = Time.now
178       @first_used = now if @count == 0
179       @last_used = now
180       @count += 1
181     end
182
183     def expired?
184       debug "checking expired?"
185       if cc = self.response['cache-control'] && cc =~ /must-revalidate/
186         return true
187       end
188       return self.expires < Time.now
189     end
190
191     def setup_headers(hdr)
192       hdr['if-modified-since'] = self.date.rfc2822
193
194       debug "ims == #{hdr['if-modified-since']}"
195
196       if etag = self.response['etag']
197         hdr['if-none-match'] = etag
198         debug "etag: #{etag}"
199       end
200     end
201
202     def revalidate(resp = self.response)
203       @count = 0
204       self.use
205       self.date = resp.key?('date') ? Time.httpdate(resp['date']) : Time.now
206
207       cc = resp['cache-control']
208       if cc && (cc =~ /max-age=(\d+)/)
209         self.expires = self.date + $1.to_i
210       elsif resp.key?('expires')
211         self.expires = Time.httpdate(resp['expires'])
212       elsif lm = resp['last-modified']
213         delta = self.date - Time.httpdate(lm)
214         delta = 10 if delta <= 0
215         delta /= 5
216         self.expires = self.date + delta
217       else
218         self.expires = self.date + 300
219       end
220       # self.expires = Time.now + 10 # DEBUG
221       debug "expires on #{self.expires}"
222
223       return true
224     end
225
226     private
227     def initialize(resp)
228       @response = resp
229       begin
230         self.revalidate
231         self.response.raw_body
232       rescue Exception => e
233         error e.message
234         error e.backtrace.join("\n")
235         raise e
236       end
237     end
238   end
239
240   def initialize(bot)
241     @bot = bot
242     @cache = Hash.new
243     @headers = {
244       'Accept-Charset' => 'utf-8;q=1.0, *;q=0.8',
245       'Accept-Encoding' => 'gzip;q=1, identity;q=0.8, *;q=0.2',
246       'User-Agent' =>
247         "rbot http util #{$version} (http://linuxbrit.co.uk/rbot/)"
248     } 
249     debug "starting http cache cleanup timer"
250     @timer = @bot.timer.add(300) {
251       self.remove_stale_cache unless @bot.config['http.no_expire_cache']
252     }
253   end 
254
255   def cleanup
256     debug 'stopping http cache cleanup timer'
257     @bot.timer.remove(@timer)
258   end
259
260   # if http_proxy_include or http_proxy_exclude are set, then examine the
261   # uri to see if this is a proxied uri
262   # the in/excludes are a list of regexps, and each regexp is checked against
263   # the server name, and its IP addresses
264   def proxy_required(uri)
265     use_proxy = true
266     if @bot.config["http.proxy_exclude"].empty? && @bot.config["http.proxy_include"].empty?
267       return use_proxy
268     end
269
270     list = [uri.host]
271     begin
272       list.concat Resolv.getaddresses(uri.host)
273     rescue StandardError => err
274       warning "couldn't resolve host uri.host"
275     end
276
277     unless @bot.config["http.proxy_exclude"].empty?
278       re = @bot.config["http.proxy_exclude"].collect{|r| Regexp.new(r)}
279       re.each do |r|
280         list.each do |item|
281           if r.match(item)
282             use_proxy = false
283             break
284           end
285         end
286       end
287     end
288     unless @bot.config["http.proxy_include"].empty?
289       re = @bot.config["http.proxy_include"].collect{|r| Regexp.new(r)}
290       re.each do |r|
291         list.each do |item|
292           if r.match(item)
293             use_proxy = true
294             break
295           end
296         end
297       end
298     end
299     debug "using proxy for uri #{uri}?: #{use_proxy}"
300     return use_proxy
301   end
302
303   # uri:: Uri to create a proxy for
304   #
305   # return a net/http Proxy object, which is configured correctly for
306   # proxying based on the bot's proxy configuration.
307   # This will include per-url proxy configuration based on the bot config
308   # +http_proxy_include/exclude+ options.
309   
310   def get_proxy(uri, options = {})
311     opts = {
312       :read_timeout => 10,
313       :open_timeout => 5
314     }.merge(options)
315
316     proxy = nil
317     proxy_host = nil
318     proxy_port = nil
319     proxy_user = nil
320     proxy_pass = nil
321
322     if @bot.config["http.use_proxy"]
323       if (ENV['http_proxy'])
324         proxy = URI.parse ENV['http_proxy'] rescue nil
325       end
326       if (@bot.config["http.proxy_uri"])
327         proxy = URI.parse @bot.config["http.proxy_uri"] rescue nil
328       end
329       if proxy
330         debug "proxy is set to #{proxy.host} port #{proxy.port}"
331         if proxy_required(uri)
332           proxy_host = proxy.host
333           proxy_port = proxy.port
334           proxy_user = @bot.config["http.proxy_user"]
335           proxy_pass = @bot.config["http.proxy_pass"]
336         end
337       end
338     end
339
340     h = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_port)
341     h.use_ssl = true if uri.scheme == "https"
342
343     h.read_timeout = opts[:read_timeout]
344     h.open_timeout = opts[:open_timeout]
345     return h
346   end
347
348   def handle_response(uri, resp, opts, &block)
349     if Net::HTTPRedirection === resp && opts[:max_redir] >= 0
350       if resp.key?('location')
351         raise 'Too many redirections' if opts[:max_redir] <= 0
352         yield resp if opts[:yield] == :all && block_given?
353         loc = resp['location']
354         new_loc = URI.join(uri.to_s, loc) rescue URI.parse(loc)
355         new_opts = opts.dup
356         new_opts[:max_redir] -= 1
357         case opts[:method].to_s.downcase.intern
358         when :post, :"net::http::post"
359           new_opts[:method] = :get
360         end
361         debug "following the redirect to #{new_loc}"
362         return get_response(new_loc, new_opts, &block)
363       else
364         warning ":| redirect w/o location?"
365       end
366     end
367     if block_given?
368       yield(resp)
369     else
370       # Net::HTTP wants us to read the whole body here
371       resp.raw_body
372     end
373     return resp
374   end
375
376   # uri::         uri to query (Uri object or String)
377   # opts::        options. Currently used:
378   # :method::     request method [:get (default), :post or :head]
379   # :open_timeout::     open timeout for the proxy
380   # :read_timeout::     read timeout for the proxy
381   # :cache::            should we cache results?
382   # :yield::      if :final [default], call &block for the response object
383   #               if :all, call &block for all intermediate redirects, too
384   # :max_redir::  how many redirects to follow before raising the exception
385   #               if -1, don't follow redirects, just return them
386   # :range::      make a ranged request (usually GET). accepts a string
387   #               for HTTP/1.1 "Range:" header (i.e. "bytes=0-1000")
388   # :body::       request body (usually for POST requests)
389   #
390   # Generic http transaction method
391   #
392   # It will return a HTTP::Response object or raise an exception
393   #
394   # If a block is given, it will yield the response (see :yield option)
395
396   def get_response(uri_or_s, options = {}, &block)
397     uri = uri_or_s.kind_of?(URI) ? uri_or_s : URI.parse(uri_or_s.to_s)
398     opts = {
399       :max_redir => @bot.config['http.max_redir'],
400       :yield => :final,
401       :cache => true,
402       :method => :GET
403     }.merge(options)
404
405     resp = nil
406     cached = nil
407
408     req_class = case opts[:method].to_s.downcase.intern
409                 when :head, :"net::http::head"
410                   opts[:max_redir] = -1
411                   Net::HTTP::Head
412                 when :get, :"net::http::get"
413                   Net::HTTP::Get
414                 when :post, :"net::http::post"
415                   opts[:cache] = false
416                   opts[:body] or raise 'post request w/o a body?'
417                   warning "refusing to cache POST request" if options[:cache]
418                   Net::HTTP::Post
419                 else
420                   warning "unsupported method #{opts[:method]}, doing GET"
421                   Net::HTTP::Get
422                 end
423
424     if req_class != Net::HTTP::Get && opts[:range]
425       warning "can't request ranges for #{req_class}"
426       opts.delete(:range)
427     end
428
429     cache_key = "#{opts[:range]}|#{req_class}|#{uri.to_s}"
430
431     if req_class != Net::HTTP::Get && req_class != Net::HTTP::Head
432       if opts[:cache]
433         warning "can't cache #{req_class.inspect} requests, working w/o cache"
434         opts[:cache] = false
435       end
436     end
437
438     debug "get_response(#{uri}, #{opts.inspect})"
439
440     if opts[:cache] && cached = @cache[cache_key]
441       debug "got cached"
442       if !cached.expired?
443         debug "using cached"
444         cached.use
445         return handle_response(uri, cached.response, opts, &block)
446       end
447     end
448     
449     headers = @headers.dup.merge(opts[:headers] || {})
450     headers['Range'] = opts[:range] if opts[:range]
451
452     cached.setup_headers(headers) if cached && (req_class == Net::HTTP::Get)
453     req = req_class.new(uri.request_uri, headers)
454     req.basic_auth(uri.user, uri.password) if uri.user && uri.password
455     req.body = opts[:body] if req_class == Net::HTTP::Post
456     debug "prepared request: #{req.to_hash.inspect}"
457
458     get_proxy(uri, opts).start do |http|
459       http.request(req) do |resp|
460         resp['x-rbot-location'] = uri.to_s
461         if Net::HTTPNotModified === resp
462           debug "not modified"
463           begin
464             cached.revalidate(resp)
465           rescue Exception => e
466             error e.message
467             error e.backtrace.join("\n")
468           end
469           debug "reusing cached"
470           resp = cached.response
471         elsif Net::HTTPServerError === resp || Net::HTTPClientError === resp
472           debug "http error, deleting cached obj" if cached
473           @cache.delete(cache_key)
474         elsif opts[:cache]
475           begin
476             return handle_response(uri, resp, opts, &block)
477           ensure
478             if cached = CachedObject.maybe_new(resp) rescue nil
479               debug "storing to cache"
480               @cache[cache_key] = cached
481             end
482           end
483           return ret
484         end
485         return handle_response(uri, resp, opts, &block)
486       end
487     end
488   end
489
490   # uri::         uri to query (Uri object)
491   #
492   # simple get request, returns (if possible) response body following redirs
493   # and caching if requested
494   def get(uri, opts = {}, &block)
495     begin
496       resp = get_response(uri, opts, &block)
497       raise "http error: #{resp}" unless Net::HTTPOK === resp ||
498         Net::HTTPPartialContent === resp
499       return resp.body
500     rescue Exception => e
501       error e.message
502       error e.backtrace.join("\n")
503     end
504     return nil
505   end
506
507   def head(uri, options = {}, &block)
508     opts = {:method => :head}.merge(options)
509     begin
510       resp = get_response(uri, opts, &block)
511       raise "http error #{resp}" if Net::HTTPClientError === resp ||
512         Net::HTTPServerError == resp
513       return resp
514     rescue Exception => e
515       error e.message
516       error e.backtrace.join("\n")
517     end
518     return nil
519   end
520
521   def post(uri, data, options = {}, &block)
522     opts = {:method => :post, :body => data, :cache => false}.merge(options)
523     begin
524       resp = get_response(uri, opts, &block)
525       raise 'http error' unless Net::HTTPOK === resp
526       return resp
527     rescue Exception => e
528       error e.message
529       error e.backtrace.join("\n")
530     end
531     return nil
532   end
533
534   def get_partial(uri, nbytes = @bot.config['http.info_bytes'], options = {}, &block)
535     opts = {:range => "bytes=0-#{nbytes}"}.merge(options)
536     return get(uri, opts, &block)
537   end
538
539   def remove_stale_cache
540     debug "Removing stale cache"
541     now = Time.new
542     max_last = @bot.config['http.expire_time'] * 60
543     max_first = @bot.config['http.max_cache_time'] * 60
544     debug "#{@cache.size} pages before"
545     begin
546       @cache.reject! { |k, val|
547         (now - val.last_used > max_last) || (now - val.first_used > max_first)
548       }
549     rescue => e
550       error "Failed to remove stale cache: #{e.inspect}"
551     end
552     debug "#{@cache.size} pages after"
553   end
554
555 end
556 end
557 end
558
559 class HttpUtilPlugin < CoreBotModule
560   def initialize(*a)
561     super(*a)
562     debug 'initializing httputil'
563     @bot.httputil = Irc::Utils::HttpUtil.new(@bot)
564   end
565
566   def cleanup
567     debug 'shutting down httputil'
568     @bot.httputil.cleanup
569     @bot.httputil = nil
570   end
571 end
572
573 HttpUtilPlugin.new