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